From 8225f8c3a18aa3628e89dc37d7f2410bdeaf830e Mon Sep 17 00:00:00 2001 From: f3kilo Date: Mon, 3 Feb 2025 12:40:43 +0300 Subject: [PATCH 01/11] http outcall libs --- Cargo.toml | 3 + ic-helpers/src/principal/management.rs | 17 +++- ic-http-outcall/api/Cargo.toml | 17 ++++ ic-http-outcall/api/src/lib.rs | 28 +++++++ ic-http-outcall/api/src/non_replicated.rs | 65 +++++++++++++++ ic-http-outcall/api/src/outcall.rs | 12 +++ ic-http-outcall/api/src/proxy_types.rs | 39 +++++++++ ic-http-outcall/api/src/replicated.rs | 16 ++++ ic-http-outcall/proxy-canister/Cargo.toml | 17 ++++ ic-http-outcall/proxy-canister/src/lib.rs | 97 +++++++++++++++++++++++ ic-http-outcall/proxy-client/Cargo.toml | 6 ++ ic-http-outcall/proxy-client/src/main.rs | 3 + 12 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 ic-http-outcall/api/Cargo.toml create mode 100644 ic-http-outcall/api/src/lib.rs create mode 100644 ic-http-outcall/api/src/non_replicated.rs create mode 100644 ic-http-outcall/api/src/outcall.rs create mode 100644 ic-http-outcall/api/src/proxy_types.rs create mode 100644 ic-http-outcall/api/src/replicated.rs create mode 100644 ic-http-outcall/proxy-canister/Cargo.toml create mode 100644 ic-http-outcall/proxy-canister/src/lib.rs create mode 100644 ic-http-outcall/proxy-client/Cargo.toml create mode 100644 ic-http-outcall/proxy-client/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index c6415b52..a978dbf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ members = [ "ic-task-scheduler", "ic-task-scheduler/tests/dummy_scheduler_canister", "ic-test-utils", + "ic-http-outcall/api", + "ic-http-outcall/proxy-canister", + "ic-http-outcall/proxy-client", ] [workspace.package] diff --git a/ic-helpers/src/principal/management.rs b/ic-helpers/src/principal/management.rs index 9385b72a..99603594 100644 --- a/ic-helpers/src/principal/management.rs +++ b/ic-helpers/src/principal/management.rs @@ -15,8 +15,11 @@ use ic_exports::ic_cdk::api::management_canister::ecdsa::{ EcdsaCurve, EcdsaKeyId, EcdsaPublicKeyArgument, EcdsaPublicKeyResponse, SignWithEcdsaArgument, SignWithEcdsaResponse, }; +use ic_exports::ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, +}; use ic_exports::ic_cdk::api::management_canister::provisional::CanisterId; -use ic_exports::ic_kit::ic; +use ic_exports::ic_kit::{ic, CallResult}; use serde::{Deserialize, Serialize}; use super::private::Sealed; @@ -143,6 +146,8 @@ pub trait ManagementPrincipalExt: Sealed { async fn deposit_cycles(&self) -> Result<(), (RejectionCode, String)>; async fn raw_rand(&self) -> Result, (RejectionCode, String)>; async fn provisional_top_up(&self, amount: Nat) -> Result<(), (RejectionCode, String)>; + + async fn http_request(&self, args: CanisterHttpRequestArgument) -> CallResult; } #[async_trait] @@ -345,6 +350,16 @@ impl ManagementPrincipalExt for Principal { ) .await } + + async fn http_request(&self, args: CanisterHttpRequestArgument) -> CallResult { + virtual_canister_call!( + Principal::management_canister(), + "http_request", + (args,), + HttpResponse + ) + .await + } } #[derive(CandidType, Serialize, Deserialize, Debug)] diff --git a/ic-http-outcall/api/Cargo.toml b/ic-http-outcall/api/Cargo.toml new file mode 100644 index 00000000..0450d931 --- /dev/null +++ b/ic-http-outcall/api/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ic-http-outcall-api" +version.workspace = true +edition.workspace = true + +[dependencies] +ic-exports = { path = "../../ic-exports" } +ic-helpers = { path = "../../ic-helpers" } +ic-canister = { path = "../../ic-canister/ic-canister", optional = true } + +candid = { workspace = true } +serde = { workspace = true } +futures = { workspace = true } + +[features] +proxy-api = [] +non-rep = ["proxy-api", "ic-canister"] diff --git a/ic-http-outcall/api/src/lib.rs b/ic-http-outcall/api/src/lib.rs new file mode 100644 index 00000000..24f0af3e --- /dev/null +++ b/ic-http-outcall/api/src/lib.rs @@ -0,0 +1,28 @@ +//! This crate provides an abstraction over different implementations of IC +//! Http outcall mechanism. +//! +//! The absraction is described as `HttpOutcall` trait. It has the +//! following implementations: +//! - `NonReplicatedHttpOutcall` - performs non-replicated call. +//! Details: `https://forum.dfinity.org/t/non-replicated-https-outcalls/26627`; +//! +//! - `ReplicatedHttpOutcall` - perform replicated calls using basic `http_outcall` +//! method in IC API. + +#[cfg(feature = "non-rep")] +mod non_replicated; +#[cfg(feature = "proxy-api")] +mod proxy_types; + +mod outcall; +mod replicated; + +pub use outcall::HttpOutcall; + +#[cfg(feature = "proxy-api")] +pub use proxy_types::{InitArgs, OnResponseArgs, RequestArgs, RequestId, REQUEST_METHOD_NAME}; + +#[cfg(feature = "non-rep")] +pub use non_replicated::NonReplicatedHttpOutcall; + +pub use replicated::ReplicatedHttpOutcall; diff --git a/ic-http-outcall/api/src/non_replicated.rs b/ic-http-outcall/api/src/non_replicated.rs new file mode 100644 index 00000000..9d0f19e0 --- /dev/null +++ b/ic-http-outcall/api/src/non_replicated.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; + +use candid::Principal; +use futures::channel::oneshot; +use ic_canister::virtual_canister_call; +use ic_exports::{ + ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, + ic_kit::CallResult, +}; + +use crate::{ + outcall::HttpOutcall, + proxy_types::{OnResponseArgs, RequestArgs, RequestId, REQUEST_METHOD_NAME}, +}; + +#[derive(Debug)] +pub struct NonReplicatedHttpOutcall { + requests: HashMap, + callback_api_fn_name: &'static str, + proxy_canister: Principal, +} + +impl NonReplicatedHttpOutcall { + pub fn new(proxy_canister: Principal, callback_api_fn_name: &'static str) -> Self { + Self { + requests: Default::default(), + callback_api_fn_name, + proxy_canister, + } + } + + pub fn on_response(&mut self, args: OnResponseArgs) { + if let Some(response) = self.requests.remove(&args.request_id) { + let _ = response.notify.send(args.response); + } + } +} + +impl HttpOutcall for NonReplicatedHttpOutcall { + async fn request(&mut self, request: CanisterHttpRequestArgument) -> CallResult { + let proxy_canister = self.proxy_canister; + let request = RequestArgs { + callback_name: self.callback_api_fn_name.into(), + request, + }; + let id: RequestId = + virtual_canister_call!(proxy_canister, REQUEST_METHOD_NAME, (request,), RequestId) + .await?; + + let (notify, waker) = oneshot::channel(); + let response = DeferredResponse { notify }; + self.requests.insert(id, response); + + Ok(waker.await.unwrap_or_else(|_| HttpResponse { + status: 408_u64.into(), // timeout error + headers: vec![], + body: vec![], + })) + } +} + +#[derive(Debug)] +struct DeferredResponse { + pub notify: oneshot::Sender, +} diff --git a/ic-http-outcall/api/src/outcall.rs b/ic-http-outcall/api/src/outcall.rs new file mode 100644 index 00000000..3da43472 --- /dev/null +++ b/ic-http-outcall/api/src/outcall.rs @@ -0,0 +1,12 @@ +//! Abstraction over Http outcalls. + +use ic_exports::{ + ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, + ic_kit::CallResult, +}; + +/// Abstraction over Http outcalls. +#[allow(async_fn_in_trait)] +pub trait HttpOutcall { + async fn request(&mut self, args: CanisterHttpRequestArgument) -> CallResult; +} diff --git a/ic-http-outcall/api/src/proxy_types.rs b/ic-http-outcall/api/src/proxy_types.rs new file mode 100644 index 00000000..635bb7d5 --- /dev/null +++ b/ic-http-outcall/api/src/proxy_types.rs @@ -0,0 +1,39 @@ +use candid::Principal; +use ic_exports::{ + candid::CandidType, + ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, +}; +use serde::{Deserialize, Serialize}; + +pub const REQUEST_METHOD_NAME: &str = "http_outcall"; + +/// Params for proxy canister initialization. +#[derive(Debug, Serialize, Deserialize, CandidType)] +pub struct InitArgs { + /// Off-chain proxy agent, allowed to perform http requests. + pub allowed_proxy: Principal, +} + +/// ID of a request. +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, CandidType, PartialEq, Eq, PartialOrd, Ord, Hash, +)] +pub struct RequestId(u64); + +impl From for RequestId { + fn from(value: u64) -> Self { + Self(value) + } +} + +#[derive(Debug, Clone, Deserialize, CandidType)] +pub struct RequestArgs { + pub callback_name: String, + pub request: CanisterHttpRequestArgument, +} + +#[derive(Debug, Clone, Serialize, Deserialize, CandidType)] +pub struct OnResponseArgs { + pub request_id: RequestId, + pub response: HttpResponse, +} diff --git a/ic-http-outcall/api/src/replicated.rs b/ic-http-outcall/api/src/replicated.rs new file mode 100644 index 00000000..36a52f12 --- /dev/null +++ b/ic-http-outcall/api/src/replicated.rs @@ -0,0 +1,16 @@ +use candid::Principal; +use ic_exports::{ + ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, + ic_kit::CallResult, +}; +use ic_helpers::principal::management::ManagementPrincipalExt; + +use crate::outcall::HttpOutcall; + +pub struct ReplicatedHttpOutcall; + +impl HttpOutcall for ReplicatedHttpOutcall { + async fn request(&mut self, args: CanisterHttpRequestArgument) -> CallResult { + Principal::management_canister().http_request(args).await + } +} diff --git a/ic-http-outcall/proxy-canister/Cargo.toml b/ic-http-outcall/proxy-canister/Cargo.toml new file mode 100644 index 00000000..4e857005 --- /dev/null +++ b/ic-http-outcall/proxy-canister/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "proxy-canister" +version.workspace = true +edition.workspace = true + +[features] +default = [] +export-api = [] + +[dependencies] +ic-canister = { path = "../../ic-canister/ic-canister" } +ic-exports = { path = "../../ic-exports" } +ic-http-outcall-api = { path = "../api", features = ["proxy-api"] } + + +candid = { workspace = true } +serde = { workspace = true } diff --git a/ic-http-outcall/proxy-canister/src/lib.rs b/ic-http-outcall/proxy-canister/src/lib.rs new file mode 100644 index 00000000..b8201dd2 --- /dev/null +++ b/ic-http-outcall/proxy-canister/src/lib.rs @@ -0,0 +1,97 @@ +use std::{ + cell::{OnceCell, RefCell}, + collections::HashMap, + sync::atomic::{AtomicU64, Ordering}, +}; + +use candid::Principal; +use ic_canister::{init, query, update, virtual_canister_call, Canister, PreUpdate}; +use ic_exports::{ + ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, + ic_kit::ic, +}; +use ic_http_outcall_api::{InitArgs, RequestArgs, RequestId}; + +static IDS_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Canister)] +#[canister_no_upgrade_methods] +pub struct HttpProxyCanister { + #[id] + principal: Principal, +} + +impl PreUpdate for HttpProxyCanister {} + +impl HttpProxyCanister { + #[init] + pub async fn init(&mut self, args: InitArgs) { + ALLOWED_PROXY.with(move |cell| { + cell.get_or_init(move || args.allowed_proxy); + }); + } + + #[update] + pub fn http_outcall(&mut self, args: RequestArgs) -> RequestId { + let sender = ic::caller(); + let id = IDS_COUNTER.fetch_add(1, Ordering::Relaxed).into(); + let with_sender = RequestWithSender { args, sender }; + + PENDING_REQUESTS.with_borrow_mut(move |map| map.insert(id, with_sender)); + + id + } + + #[query] + pub fn pending_requests(&self, limit: usize) -> Vec<(RequestId, CanisterHttpRequestArgument)> { + check_allowed_proxy(ic::caller()); + + PENDING_REQUESTS.with_borrow(|map| { + map.iter() + .take(limit) + .map(|(k, v)| (*k, v.args.request.clone())) + .collect() + }) + } + + #[update] + pub async fn finish_requests(&mut self, responses: Vec<(RequestId, HttpResponse)>) { + check_allowed_proxy(ic::caller()); + + for (id, response) in responses { + let Some(request) = PENDING_REQUESTS.with_borrow_mut(|map| map.remove(&id)) else { + continue; + }; + + ic::spawn(async move { + let _ = virtual_canister_call!( + request.sender, + &request.args.callback_name, + (response,), + () + ); + }); + } + } +} + +fn check_allowed_proxy(proxy: Principal) { + let allowed_proxy = ALLOWED_PROXY + .with(|val| val.get().copied()) + .expect("allowed proxy to be initialized"); + + if proxy != allowed_proxy { + ic::trap("only allowed proxy may process requests") + } +} + +#[derive(Debug, Clone)] +struct RequestWithSender { + pub args: RequestArgs, + pub sender: Principal, +} + +thread_local! { + static ALLOWED_PROXY: OnceCell = OnceCell::new(); + static PENDING_REQUESTS: RefCell> = RefCell::default(); +} diff --git a/ic-http-outcall/proxy-client/Cargo.toml b/ic-http-outcall/proxy-client/Cargo.toml new file mode 100644 index 00000000..f384bd0b --- /dev/null +++ b/ic-http-outcall/proxy-client/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "proxy-client" +version.workspace = true +edition.workspace = true + +[dependencies] diff --git a/ic-http-outcall/proxy-client/src/main.rs b/ic-http-outcall/proxy-client/src/main.rs new file mode 100644 index 00000000..e7a11a96 --- /dev/null +++ b/ic-http-outcall/proxy-client/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} From 04cc9e9892c05da4e5b90ca15bdbd4e59b0f80ed Mon Sep 17 00:00:00 2001 From: f3kilo Date: Mon, 3 Feb 2025 13:04:51 +0300 Subject: [PATCH 02/11] packages renamed + outcall impl for Rc RefCell --- ic-http-outcall/api/src/non_replicated.rs | 1 + ic-http-outcall/api/src/outcall.rs | 8 ++++++++ ic-http-outcall/proxy-canister/Cargo.toml | 2 +- ic-http-outcall/proxy-canister/src/lib.rs | 5 +++-- ic-http-outcall/proxy-client/Cargo.toml | 2 +- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ic-http-outcall/api/src/non_replicated.rs b/ic-http-outcall/api/src/non_replicated.rs index 9d0f19e0..04236b77 100644 --- a/ic-http-outcall/api/src/non_replicated.rs +++ b/ic-http-outcall/api/src/non_replicated.rs @@ -29,6 +29,7 @@ impl NonReplicatedHttpOutcall { } } + /// Call this function inside canister API callback for processed request. pub fn on_response(&mut self, args: OnResponseArgs) { if let Some(response) = self.requests.remove(&args.request_id) { let _ = response.notify.send(args.response); diff --git a/ic-http-outcall/api/src/outcall.rs b/ic-http-outcall/api/src/outcall.rs index 3da43472..10a3f535 100644 --- a/ic-http-outcall/api/src/outcall.rs +++ b/ic-http-outcall/api/src/outcall.rs @@ -1,5 +1,7 @@ //! Abstraction over Http outcalls. +use std::{cell::RefCell, rc::Rc}; + use ic_exports::{ ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, ic_kit::CallResult, @@ -10,3 +12,9 @@ use ic_exports::{ pub trait HttpOutcall { async fn request(&mut self, args: CanisterHttpRequestArgument) -> CallResult; } + +impl HttpOutcall for Rc> { + async fn request(&mut self, args: CanisterHttpRequestArgument) -> CallResult { + RefCell::borrow_mut(self).request(args).await + } +} diff --git a/ic-http-outcall/proxy-canister/Cargo.toml b/ic-http-outcall/proxy-canister/Cargo.toml index 4e857005..682a903d 100644 --- a/ic-http-outcall/proxy-canister/Cargo.toml +++ b/ic-http-outcall/proxy-canister/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "proxy-canister" +name = "ic-http-outcall-proxy-canister" version.workspace = true edition.workspace = true diff --git a/ic-http-outcall/proxy-canister/src/lib.rs b/ic-http-outcall/proxy-canister/src/lib.rs index b8201dd2..4c30e5e5 100644 --- a/ic-http-outcall/proxy-canister/src/lib.rs +++ b/ic-http-outcall/proxy-canister/src/lib.rs @@ -69,7 +69,8 @@ impl HttpProxyCanister { &request.args.callback_name, (response,), () - ); + ) + .await; }); } } @@ -92,6 +93,6 @@ struct RequestWithSender { } thread_local! { - static ALLOWED_PROXY: OnceCell = OnceCell::new(); + static ALLOWED_PROXY: OnceCell = const { OnceCell::new() }; static PENDING_REQUESTS: RefCell> = RefCell::default(); } diff --git a/ic-http-outcall/proxy-client/Cargo.toml b/ic-http-outcall/proxy-client/Cargo.toml index f384bd0b..e0c0d173 100644 --- a/ic-http-outcall/proxy-client/Cargo.toml +++ b/ic-http-outcall/proxy-client/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "proxy-client" +name = "ic-http-outcall-proxy-client" version.workspace = true edition.workspace = true From 0325f59fbd63aa6d4b7c8a276935aa2fcef653ed Mon Sep 17 00:00:00 2001 From: f3kilo Date: Tue, 4 Feb 2025 12:04:40 +0300 Subject: [PATCH 03/11] proxy client --- Cargo.toml | 2 + ic-http-outcall/api/src/lib.rs | 2 +- ic-http-outcall/api/src/non_replicated.rs | 36 +++- ic-http-outcall/api/src/proxy_types.rs | 6 +- ic-http-outcall/proxy-canister/src/lib.rs | 12 +- ic-http-outcall/proxy-client/Cargo.toml | 14 ++ ic-http-outcall/proxy-client/src/main.rs | 239 +++++++++++++++++++++- 7 files changed, 288 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a978dbf7..eb82a60d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ async-trait = "0.1" auto_ops = "0.3" bincode = "1.3" cfg-if = "1.0" +clap = "4.5" criterion = "0.5.1" crypto-bigint = { version = "0.5", features = ["serde"] } dirs = "5.0" @@ -76,6 +77,7 @@ tempfile = "3.14" thiserror = "2.0" tokio = "1.41" trybuild = "1.0" +env_logger = "0.11" # IC dependencies candid = "0.10" diff --git a/ic-http-outcall/api/src/lib.rs b/ic-http-outcall/api/src/lib.rs index 24f0af3e..b560dab0 100644 --- a/ic-http-outcall/api/src/lib.rs +++ b/ic-http-outcall/api/src/lib.rs @@ -20,7 +20,7 @@ mod replicated; pub use outcall::HttpOutcall; #[cfg(feature = "proxy-api")] -pub use proxy_types::{InitArgs, OnResponseArgs, RequestArgs, RequestId, REQUEST_METHOD_NAME}; +pub use proxy_types::{InitArgs, RequestArgs, RequestId, ResponseResult, REQUEST_METHOD_NAME}; #[cfg(feature = "non-rep")] pub use non_replicated::NonReplicatedHttpOutcall; diff --git a/ic-http-outcall/api/src/non_replicated.rs b/ic-http-outcall/api/src/non_replicated.rs index 04236b77..645a947c 100644 --- a/ic-http-outcall/api/src/non_replicated.rs +++ b/ic-http-outcall/api/src/non_replicated.rs @@ -5,12 +5,13 @@ use futures::channel::oneshot; use ic_canister::virtual_canister_call; use ic_exports::{ ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, - ic_kit::CallResult, + ic_kit::{CallResult, RejectionCode}, }; use crate::{ outcall::HttpOutcall, - proxy_types::{OnResponseArgs, RequestArgs, RequestId, REQUEST_METHOD_NAME}, + proxy_types::{RequestArgs, RequestId, REQUEST_METHOD_NAME}, + ResponseResult, }; #[derive(Debug)] @@ -21,6 +22,8 @@ pub struct NonReplicatedHttpOutcall { } impl NonReplicatedHttpOutcall { + /// The `callback_api_fn_name` function expected to have the following signature: + /// - `fn(ResponseResult) -> ()` pub fn new(proxy_canister: Principal, callback_api_fn_name: &'static str) -> Self { Self { requests: Default::default(), @@ -30,9 +33,9 @@ impl NonReplicatedHttpOutcall { } /// Call this function inside canister API callback for processed request. - pub fn on_response(&mut self, args: OnResponseArgs) { - if let Some(response) = self.requests.remove(&args.request_id) { - let _ = response.notify.send(args.response); + pub fn on_response(&mut self, result: ResponseResult) { + if let Some(response) = self.requests.remove(&result.id) { + let _ = response.notify.send(result.result); } } } @@ -52,15 +55,26 @@ impl HttpOutcall for NonReplicatedHttpOutcall { let response = DeferredResponse { notify }; self.requests.insert(id, response); - Ok(waker.await.unwrap_or_else(|_| HttpResponse { - status: 408_u64.into(), // timeout error - headers: vec![], - body: vec![], - })) + waker + .await + .map_err(|_| { + // if proxy canister doesn't respond + ( + RejectionCode::SysTransient, + "timeout waiting HTTP request callback.".into(), + ) + })? + .map_err(|e| { + // if request failed + ( + RejectionCode::SysFatal, + format!("failed to send HTTP request: {e}"), + ) + }) } } #[derive(Debug)] struct DeferredResponse { - pub notify: oneshot::Sender, + pub notify: oneshot::Sender>, } diff --git a/ic-http-outcall/api/src/proxy_types.rs b/ic-http-outcall/api/src/proxy_types.rs index 635bb7d5..339dc3f2 100644 --- a/ic-http-outcall/api/src/proxy_types.rs +++ b/ic-http-outcall/api/src/proxy_types.rs @@ -33,7 +33,7 @@ pub struct RequestArgs { } #[derive(Debug, Clone, Serialize, Deserialize, CandidType)] -pub struct OnResponseArgs { - pub request_id: RequestId, - pub response: HttpResponse, +pub struct ResponseResult { + pub id: RequestId, + pub result: Result, } diff --git a/ic-http-outcall/proxy-canister/src/lib.rs b/ic-http-outcall/proxy-canister/src/lib.rs index 4c30e5e5..d270c912 100644 --- a/ic-http-outcall/proxy-canister/src/lib.rs +++ b/ic-http-outcall/proxy-canister/src/lib.rs @@ -7,10 +7,9 @@ use std::{ use candid::Principal; use ic_canister::{init, query, update, virtual_canister_call, Canister, PreUpdate}; use ic_exports::{ - ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, - ic_kit::ic, + ic_cdk::api::management_canister::http_request::CanisterHttpRequestArgument, ic_kit::ic, }; -use ic_http_outcall_api::{InitArgs, RequestArgs, RequestId}; +use ic_http_outcall_api::{InitArgs, RequestArgs, RequestId, ResponseResult}; static IDS_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -55,11 +54,12 @@ impl HttpProxyCanister { } #[update] - pub async fn finish_requests(&mut self, responses: Vec<(RequestId, HttpResponse)>) { + pub async fn finish_requests(&mut self, responses: Vec) { check_allowed_proxy(ic::caller()); - for (id, response) in responses { - let Some(request) = PENDING_REQUESTS.with_borrow_mut(|map| map.remove(&id)) else { + for response in responses { + let Some(request) = PENDING_REQUESTS.with_borrow_mut(|map| map.remove(&response.id)) + else { continue; }; diff --git a/ic-http-outcall/proxy-client/Cargo.toml b/ic-http-outcall/proxy-client/Cargo.toml index e0c0d173..d00577a6 100644 --- a/ic-http-outcall/proxy-client/Cargo.toml +++ b/ic-http-outcall/proxy-client/Cargo.toml @@ -4,3 +4,17 @@ version.workspace = true edition.workspace = true [dependencies] +ic-http-outcall-api = { path = "../api", features = ["proxy-api"] } +ic-http-outcall-proxy-canister = { path = "../proxy-canister" } +ic-exports = { path = "../../ic-exports" } +ic-canister-client = { path = "../../ic-canister-client", features = ["ic-agent-client"] } + +tokio = { workspace = true, features = ["rt", "time"] } +anyhow = { workspace = true } +futures = { workspace = true } +log = { workspace = true } +env_logger = { workspace = true } +candid = { workspace = true } +ic-agent = { workspace = true } +reqwest = { workspace = true } +clap = { workspace = true, features = ["derive"] } diff --git a/ic-http-outcall/proxy-client/src/main.rs b/ic-http-outcall/proxy-client/src/main.rs index e7a11a96..113d4872 100644 --- a/ic-http-outcall/proxy-client/src/main.rs +++ b/ic-http-outcall/proxy-client/src/main.rs @@ -1,3 +1,238 @@ -fn main() { - println!("Hello, world!"); +use std::{collections::HashSet, fmt, path::PathBuf, str::FromStr, time::Duration}; + +use candid::Principal; +use clap::Parser; +use futures::future; +use ic_canister_client::{agent::identity, CanisterClient, IcAgentClient}; +use ic_exports::ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpHeader as IcHttpHeader, HttpMethod as IcHttpMethod, + HttpResponse, +}; +use ic_http_outcall_api::{RequestId, ResponseResult}; +use reqwest::header::{HeaderName, HeaderValue}; +use tokio::time::Instant; + +#[derive(Debug, Parser)] +pub struct ProxyClientArgs { + /// Path to your identity pem file + #[arg(short = 'i', long = "identity")] + pub identity: PathBuf, + + /// IC Network url + /// Use https://icp0.io for the Internet Computer Mainnet. + #[arg(short, long, default_value = "http://127.0.0.1:8000")] + pub network_url: String, + + /// Proxy canister principal + #[arg(short, long = "canister")] + pub canister: Principal, + + /// Timeout for requestst to proxy canister in millis. + #[arg(long, default_value = "5000")] + pub timeout: u64, + + /// Proxy canister query period in millis. + #[arg(long, default_value = "200")] + pub query_period: u64, + + /// Max Http requests batch size. + #[arg(long, default_value = "20")] + pub batch_size: usize, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + env_logger::init(); + + let args = ProxyClientArgs::parse(); + log::info!("Starting with args: {args:?}."); + + let agent = identity::init_agent( + args.identity, + &args.network_url, + Some(Duration::from_millis(args.timeout)), + ) + .await?; + let agent_client = IcAgentClient::with_agent(args.canister, agent); + + log::info!("Agent client initialized."); + + let query_period = Duration::from_millis(args.query_period); + let client = ProxyClient::new(agent_client, query_period, args.batch_size); + + log::info!("Running requests processing"); + client.run().await; + + Ok(()) +} + +struct ProxyClient { + client: IcAgentClient, + query_period: Duration, + batch_size: usize, +} + +impl fmt::Debug for ProxyClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProxyClient") + .field("client", &self.client.canister_id) + .finish() + } +} + +impl ProxyClient { + pub fn new(client: IcAgentClient, query_period: Duration, batch_size: usize) -> Self { + Self { + client, + query_period, + batch_size, + } + } + + pub async fn run(self) { + // In case of outdated results of `get_pending_requests` query, store + // finished requests ids on last iteration. + let mut just_finished_ids = HashSet::with_capacity(self.batch_size); + let mut start_next_itration_at = Instant::now(); + + loop { + let now = Instant::now(); + if now < start_next_itration_at { + let wait_for = start_next_itration_at - now; + log::trace!("Waiting for {} millis", wait_for.as_millis()); + tokio::time::sleep(wait_for).await; + } + + start_next_itration_at = Instant::now() + self.query_period; + + log::info!("Query for Http requests"); + + let Ok(mut requests) = self + .get_pending_requests() + .await + .inspect_err(|e| log::warn!("Failed to get pending requests: {e}.")) + else { + continue; + }; + + requests.retain(|r| !just_finished_ids.contains(&r.0)); + + if requests.is_empty() { + continue; + } + + log::info!("Processing {} requests", requests.len()); + + let responses = self.perform_requests(requests).await; + if let Err(e) = self.finish_requests(&responses).await { + log::warn!("Failed to finish responses: {e}."); + }; + + just_finished_ids = responses.into_iter().map(|r| r.id).collect(); + } + } + + async fn get_pending_requests( + &self, + ) -> anyhow::Result> { + Ok(self + .client + .query("pending_requests", (self.batch_size,)) + .await?) + } + + async fn perform_requests( + &self, + requests: Vec<(RequestId, CanisterHttpRequestArgument)>, + ) -> Vec { + let response_futures = requests.into_iter().map(|(id, args)| async move { + let response = Self::perform_request(args).await; + ResponseResult { + id, + result: response.map_err(|e| e.to_string()), + } + }); + + future::join_all(response_futures).await + } + + async fn perform_request(args: CanisterHttpRequestArgument) -> anyhow::Result { + let method = match args.method { + IcHttpMethod::GET => reqwest::Method::GET, + IcHttpMethod::POST => reqwest::Method::POST, + IcHttpMethod::HEAD => reqwest::Method::HEAD, + }; + let url = match reqwest::Url::parse(&args.url) { + Ok(url) => url, + Err(e) => { + anyhow::bail!("Failed to parse URL from '{}': {e}.", args.url) + } + }; + + let headers = args.headers.into_iter().filter_map(|h| { + let header_name = HeaderName::from_str(&h.name) + .inspect_err(|e| { + log::warn!( + "Failed to parse HeaderName from '{}': {e}. The header is skipped.", + h.value + ) + }) + .ok()?; + let header_value = HeaderValue::from_str(&h.value) + .inspect_err(|e| { + log::warn!( + "Failed to parse HeaderValue from '{}': {e}. The header is skipped.", + h.value + ) + }) + .ok()?; + + Some((header_name, header_value)) + }); + + let response = reqwest::Client::new() + .request(method, url) + .headers(headers.collect()) + .body(args.body.unwrap_or_default()) + .send() + .await?; + + Self::into_ic_response(response).await + } + + async fn into_ic_response(reqwest_response: reqwest::Response) -> anyhow::Result { + let status = reqwest_response.status().as_u16().into(); + + let headers = reqwest_response + .headers() + .iter() + .filter_map(|(name, value)| { + let value = value + .to_str() + .inspect_err(|e| { + log::warn!( + "Failed to convert response header to string: {e}. Header will be skipped." + ) + }) + .ok()? + .into(); + + Some(IcHttpHeader { + name: name.to_string(), + value, + }) + }) + .collect(); + + let body = reqwest_response.bytes().await?; + Ok(HttpResponse { + status, + headers, + body: body.into(), + }) + } + + async fn finish_requests(&self, responses: &[ResponseResult]) -> anyhow::Result<()> { + Ok(self.client.update("finish_requests", (&responses,)).await?) + } } From fb78f0de7930af3def2afdaaaaaedda0ad2707bc Mon Sep 17 00:00:00 2001 From: f3kilo Date: Tue, 4 Feb 2025 12:04:56 +0300 Subject: [PATCH 04/11] fmt --- ic-http-outcall/api/src/lib.rs | 7 ++----- ic-http-outcall/api/src/non_replicated.rs | 14 ++++++-------- ic-http-outcall/api/src/outcall.rs | 9 +++++---- ic-http-outcall/api/src/proxy_types.rs | 6 +++--- ic-http-outcall/api/src/replicated.rs | 6 +++--- ic-http-outcall/proxy-canister/src/lib.rs | 13 +++++-------- ic-http-outcall/proxy-client/src/main.rs | 9 +++++++-- 7 files changed, 31 insertions(+), 33 deletions(-) diff --git a/ic-http-outcall/api/src/lib.rs b/ic-http-outcall/api/src/lib.rs index b560dab0..59ce725b 100644 --- a/ic-http-outcall/api/src/lib.rs +++ b/ic-http-outcall/api/src/lib.rs @@ -17,12 +17,9 @@ mod proxy_types; mod outcall; mod replicated; +#[cfg(feature = "non-rep")] +pub use non_replicated::NonReplicatedHttpOutcall; pub use outcall::HttpOutcall; - #[cfg(feature = "proxy-api")] pub use proxy_types::{InitArgs, RequestArgs, RequestId, ResponseResult, REQUEST_METHOD_NAME}; - -#[cfg(feature = "non-rep")] -pub use non_replicated::NonReplicatedHttpOutcall; - pub use replicated::ReplicatedHttpOutcall; diff --git a/ic-http-outcall/api/src/non_replicated.rs b/ic-http-outcall/api/src/non_replicated.rs index 645a947c..1b7ea65b 100644 --- a/ic-http-outcall/api/src/non_replicated.rs +++ b/ic-http-outcall/api/src/non_replicated.rs @@ -3,16 +3,14 @@ use std::collections::HashMap; use candid::Principal; use futures::channel::oneshot; use ic_canister::virtual_canister_call; -use ic_exports::{ - ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, - ic_kit::{CallResult, RejectionCode}, +use ic_exports::ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, }; +use ic_exports::ic_kit::{CallResult, RejectionCode}; -use crate::{ - outcall::HttpOutcall, - proxy_types::{RequestArgs, RequestId, REQUEST_METHOD_NAME}, - ResponseResult, -}; +use crate::outcall::HttpOutcall; +use crate::proxy_types::{RequestArgs, RequestId, REQUEST_METHOD_NAME}; +use crate::ResponseResult; #[derive(Debug)] pub struct NonReplicatedHttpOutcall { diff --git a/ic-http-outcall/api/src/outcall.rs b/ic-http-outcall/api/src/outcall.rs index 10a3f535..bcc5f792 100644 --- a/ic-http-outcall/api/src/outcall.rs +++ b/ic-http-outcall/api/src/outcall.rs @@ -1,11 +1,12 @@ //! Abstraction over Http outcalls. -use std::{cell::RefCell, rc::Rc}; +use std::cell::RefCell; +use std::rc::Rc; -use ic_exports::{ - ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, - ic_kit::CallResult, +use ic_exports::ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, }; +use ic_exports::ic_kit::CallResult; /// Abstraction over Http outcalls. #[allow(async_fn_in_trait)] diff --git a/ic-http-outcall/api/src/proxy_types.rs b/ic-http-outcall/api/src/proxy_types.rs index 339dc3f2..5ef628d2 100644 --- a/ic-http-outcall/api/src/proxy_types.rs +++ b/ic-http-outcall/api/src/proxy_types.rs @@ -1,7 +1,7 @@ use candid::Principal; -use ic_exports::{ - candid::CandidType, - ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, +use ic_exports::candid::CandidType; +use ic_exports::ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, }; use serde::{Deserialize, Serialize}; diff --git a/ic-http-outcall/api/src/replicated.rs b/ic-http-outcall/api/src/replicated.rs index 36a52f12..e26044d0 100644 --- a/ic-http-outcall/api/src/replicated.rs +++ b/ic-http-outcall/api/src/replicated.rs @@ -1,8 +1,8 @@ use candid::Principal; -use ic_exports::{ - ic_cdk::api::management_canister::http_request::{CanisterHttpRequestArgument, HttpResponse}, - ic_kit::CallResult, +use ic_exports::ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpResponse, }; +use ic_exports::ic_kit::CallResult; use ic_helpers::principal::management::ManagementPrincipalExt; use crate::outcall::HttpOutcall; diff --git a/ic-http-outcall/proxy-canister/src/lib.rs b/ic-http-outcall/proxy-canister/src/lib.rs index d270c912..9dcac837 100644 --- a/ic-http-outcall/proxy-canister/src/lib.rs +++ b/ic-http-outcall/proxy-canister/src/lib.rs @@ -1,14 +1,11 @@ -use std::{ - cell::{OnceCell, RefCell}, - collections::HashMap, - sync::atomic::{AtomicU64, Ordering}, -}; +use std::cell::{OnceCell, RefCell}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use candid::Principal; use ic_canister::{init, query, update, virtual_canister_call, Canister, PreUpdate}; -use ic_exports::{ - ic_cdk::api::management_canister::http_request::CanisterHttpRequestArgument, ic_kit::ic, -}; +use ic_exports::ic_cdk::api::management_canister::http_request::CanisterHttpRequestArgument; +use ic_exports::ic_kit::ic; use ic_http_outcall_api::{InitArgs, RequestArgs, RequestId, ResponseResult}; static IDS_COUNTER: AtomicU64 = AtomicU64::new(0); diff --git a/ic-http-outcall/proxy-client/src/main.rs b/ic-http-outcall/proxy-client/src/main.rs index 113d4872..597bce03 100644 --- a/ic-http-outcall/proxy-client/src/main.rs +++ b/ic-http-outcall/proxy-client/src/main.rs @@ -1,9 +1,14 @@ -use std::{collections::HashSet, fmt, path::PathBuf, str::FromStr, time::Duration}; +use std::collections::HashSet; +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; +use std::time::Duration; use candid::Principal; use clap::Parser; use futures::future; -use ic_canister_client::{agent::identity, CanisterClient, IcAgentClient}; +use ic_canister_client::agent::identity; +use ic_canister_client::{CanisterClient, IcAgentClient}; use ic_exports::ic_cdk::api::management_canister::http_request::{ CanisterHttpRequestArgument, HttpHeader as IcHttpHeader, HttpMethod as IcHttpMethod, HttpResponse, From d455702de259096bfd8f9cf78c8d603032aed3cc Mon Sep 17 00:00:00 2001 From: f3kilo Date: Tue, 4 Feb 2025 12:25:21 +0300 Subject: [PATCH 05/11] better callback in non-replicated responses --- ic-http-outcall/api/src/non_replicated.rs | 42 ++++++++++++++++------- ic-http-outcall/api/src/outcall.rs | 11 +----- ic-http-outcall/api/src/replicated.rs | 2 +- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/ic-http-outcall/api/src/non_replicated.rs b/ic-http-outcall/api/src/non_replicated.rs index 1b7ea65b..278b0d6c 100644 --- a/ic-http-outcall/api/src/non_replicated.rs +++ b/ic-http-outcall/api/src/non_replicated.rs @@ -1,4 +1,6 @@ +use std::cell::RefCell; use std::collections::HashMap; +use std::rc::Rc; use candid::Principal; use futures::channel::oneshot; @@ -12,34 +14,48 @@ use crate::outcall::HttpOutcall; use crate::proxy_types::{RequestArgs, RequestId, REQUEST_METHOD_NAME}; use crate::ResponseResult; +pub type OnResponse = Box)>; + #[derive(Debug)] pub struct NonReplicatedHttpOutcall { - requests: HashMap, + requests: Rc>>, callback_api_fn_name: &'static str, proxy_canister: Principal, } impl NonReplicatedHttpOutcall { /// The `callback_api_fn_name` function expected to have the following signature: - /// - `fn(ResponseResult) -> ()` - pub fn new(proxy_canister: Principal, callback_api_fn_name: &'static str) -> Self { - Self { + /// + /// ``` + /// fn(Vec) -> () + /// ``` + /// + /// and to call the returned `OnResponse` callback. + pub fn new( + proxy_canister: Principal, + callback_api_fn_name: &'static str, + ) -> (Self, OnResponse) { + let s = Self { requests: Default::default(), callback_api_fn_name, proxy_canister, - } - } + }; + + let requests = Rc::clone(&s.requests); + let callback = Box::new(move |responses: Vec| { + for response in responses { + if let Some(deferred) = requests.borrow_mut().remove(&response.id) { + let _ = deferred.notify.send(response.result); + } + } + }); - /// Call this function inside canister API callback for processed request. - pub fn on_response(&mut self, result: ResponseResult) { - if let Some(response) = self.requests.remove(&result.id) { - let _ = response.notify.send(result.result); - } + (s, callback) } } impl HttpOutcall for NonReplicatedHttpOutcall { - async fn request(&mut self, request: CanisterHttpRequestArgument) -> CallResult { + async fn request(&self, request: CanisterHttpRequestArgument) -> CallResult { let proxy_canister = self.proxy_canister; let request = RequestArgs { callback_name: self.callback_api_fn_name.into(), @@ -51,7 +67,7 @@ impl HttpOutcall for NonReplicatedHttpOutcall { let (notify, waker) = oneshot::channel(); let response = DeferredResponse { notify }; - self.requests.insert(id, response); + self.requests.borrow_mut().insert(id, response); waker .await diff --git a/ic-http-outcall/api/src/outcall.rs b/ic-http-outcall/api/src/outcall.rs index bcc5f792..8cbd5924 100644 --- a/ic-http-outcall/api/src/outcall.rs +++ b/ic-http-outcall/api/src/outcall.rs @@ -1,8 +1,5 @@ //! Abstraction over Http outcalls. -use std::cell::RefCell; -use std::rc::Rc; - use ic_exports::ic_cdk::api::management_canister::http_request::{ CanisterHttpRequestArgument, HttpResponse, }; @@ -11,11 +8,5 @@ use ic_exports::ic_kit::CallResult; /// Abstraction over Http outcalls. #[allow(async_fn_in_trait)] pub trait HttpOutcall { - async fn request(&mut self, args: CanisterHttpRequestArgument) -> CallResult; -} - -impl HttpOutcall for Rc> { - async fn request(&mut self, args: CanisterHttpRequestArgument) -> CallResult { - RefCell::borrow_mut(self).request(args).await - } + async fn request(&self, args: CanisterHttpRequestArgument) -> CallResult; } diff --git a/ic-http-outcall/api/src/replicated.rs b/ic-http-outcall/api/src/replicated.rs index e26044d0..10e38cce 100644 --- a/ic-http-outcall/api/src/replicated.rs +++ b/ic-http-outcall/api/src/replicated.rs @@ -10,7 +10,7 @@ use crate::outcall::HttpOutcall; pub struct ReplicatedHttpOutcall; impl HttpOutcall for ReplicatedHttpOutcall { - async fn request(&mut self, args: CanisterHttpRequestArgument) -> CallResult { + async fn request(&self, args: CanisterHttpRequestArgument) -> CallResult { Principal::management_canister().http_request(args).await } } From f3b2496d0e7e7389fb3f08a1852df9b8737fdea6 Mon Sep 17 00:00:00 2001 From: f3kilo Date: Tue, 4 Feb 2025 13:07:02 +0300 Subject: [PATCH 06/11] doc comments --- ic-http-outcall/api/src/non_replicated.rs | 45 +++++++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/ic-http-outcall/api/src/non_replicated.rs b/ic-http-outcall/api/src/non_replicated.rs index 278b0d6c..fd3cf4d3 100644 --- a/ic-http-outcall/api/src/non_replicated.rs +++ b/ic-http-outcall/api/src/non_replicated.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; +use std::time::Duration; use candid::Principal; use futures::channel::oneshot; @@ -8,37 +9,53 @@ use ic_canister::virtual_canister_call; use ic_exports::ic_cdk::api::management_canister::http_request::{ CanisterHttpRequestArgument, HttpResponse, }; -use ic_exports::ic_kit::{CallResult, RejectionCode}; +use ic_exports::ic_kit::{ic, CallResult, RejectionCode}; use crate::outcall::HttpOutcall; use crate::proxy_types::{RequestArgs, RequestId, REQUEST_METHOD_NAME}; use crate::ResponseResult; +/// Callback type, which should be called to make the `HttpOutcall::request` function return. pub type OnResponse = Box)>; +/// Non-replicated http outcall implementation, which works together with ProxyCanister and ProxyCanisterClient. +/// +/// # Workflow +/// 1. Client code calls `HttpOutcall::request(params)`. `Self` sends request params to ProxyCanister +/// and awaits a Waker, which will be awaken, once the ProxyCanister will call the given callback, +/// or when the timeout reached. +/// +/// 2. ProxyCanister stores request params, and waits until ProxyCanisterClient query and execute the request. +/// +/// 3. ProxyCanister notifies the current canister about the reponse, by calling the `callback_api_fn_name` API endpoint. +/// The notification should be forwarded to the `OnResponse` callback, returned from `Self::new(...)`. #[derive(Debug)] pub struct NonReplicatedHttpOutcall { requests: Rc>>, callback_api_fn_name: &'static str, proxy_canister: Principal, + request_timeout: Duration, } impl NonReplicatedHttpOutcall { - /// The `callback_api_fn_name` function expected to have the following signature: + /// Crates a new instance of NonReplicatedHttpOutcall and a callback, which should be called + /// from canister API `callback_api_fn_name` function for response processing. /// - /// ``` - /// fn(Vec) -> () - /// ``` - /// - /// and to call the returned `OnResponse` callback. + /// The `callback_api_fn_name` expected to + /// - have a name equal to `callback_api_fn_name` value, + /// - be a canister API update endpoint, + /// - have the following signature: `fn(Vec) -> ()` + /// - call the returned `OnResponse` callback. pub fn new( proxy_canister: Principal, callback_api_fn_name: &'static str, + request_timeout: Duration, ) -> (Self, OnResponse) { let s = Self { requests: Default::default(), callback_api_fn_name, proxy_canister, + request_timeout, }; let requests = Rc::clone(&s.requests); @@ -52,10 +69,20 @@ impl NonReplicatedHttpOutcall { (s, callback) } + + /// Checks if some requests are expired, and, if so, finishs them with timeout error. + pub fn check_requests_timeout(&self) { + let now = ic::time(); + self.requests + .borrow_mut() + .retain(|_, deffered| deffered.expired_at > now); + } } impl HttpOutcall for NonReplicatedHttpOutcall { async fn request(&self, request: CanisterHttpRequestArgument) -> CallResult { + self.check_requests_timeout(); + let proxy_canister = self.proxy_canister; let request = RequestArgs { callback_name: self.callback_api_fn_name.into(), @@ -66,7 +93,8 @@ impl HttpOutcall for NonReplicatedHttpOutcall { .await?; let (notify, waker) = oneshot::channel(); - let response = DeferredResponse { notify }; + let expired_at = ic::time() + self.request_timeout.as_nanos() as u64; + let response = DeferredResponse { expired_at, notify }; self.requests.borrow_mut().insert(id, response); waker @@ -90,5 +118,6 @@ impl HttpOutcall for NonReplicatedHttpOutcall { #[derive(Debug)] struct DeferredResponse { + pub expired_at: u64, pub notify: oneshot::Sender>, } From 38b1c57a5b192cdf24bd62fe3bf94aac78a15b26 Mon Sep 17 00:00:00 2001 From: f3kilo Date: Tue, 4 Feb 2025 13:26:11 +0300 Subject: [PATCH 07/11] build fix --- ic-http-outcall/api/Cargo.toml | 4 ++-- ic-http-outcall/api/src/lib.rs | 4 ++-- ic-http-outcall/proxy-client/Cargo.toml | 2 +- ic-http-outcall/proxy-client/src/main.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ic-http-outcall/api/Cargo.toml b/ic-http-outcall/api/Cargo.toml index 0450d931..8a82d032 100644 --- a/ic-http-outcall/api/Cargo.toml +++ b/ic-http-outcall/api/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true [dependencies] ic-exports = { path = "../../ic-exports" } -ic-helpers = { path = "../../ic-helpers" } +ic-helpers = { path = "../../ic-helpers", features = ["management_canister"] } ic-canister = { path = "../../ic-canister/ic-canister", optional = true } candid = { workspace = true } @@ -14,4 +14,4 @@ futures = { workspace = true } [features] proxy-api = [] -non-rep = ["proxy-api", "ic-canister"] +non-replicated = ["proxy-api", "ic-canister"] diff --git a/ic-http-outcall/api/src/lib.rs b/ic-http-outcall/api/src/lib.rs index 59ce725b..6ee72b22 100644 --- a/ic-http-outcall/api/src/lib.rs +++ b/ic-http-outcall/api/src/lib.rs @@ -9,7 +9,7 @@ //! - `ReplicatedHttpOutcall` - perform replicated calls using basic `http_outcall` //! method in IC API. -#[cfg(feature = "non-rep")] +#[cfg(feature = "non-replicated")] mod non_replicated; #[cfg(feature = "proxy-api")] mod proxy_types; @@ -17,7 +17,7 @@ mod proxy_types; mod outcall; mod replicated; -#[cfg(feature = "non-rep")] +#[cfg(feature = "non-replicated")] pub use non_replicated::NonReplicatedHttpOutcall; pub use outcall::HttpOutcall; #[cfg(feature = "proxy-api")] diff --git a/ic-http-outcall/proxy-client/Cargo.toml b/ic-http-outcall/proxy-client/Cargo.toml index d00577a6..6f558ffc 100644 --- a/ic-http-outcall/proxy-client/Cargo.toml +++ b/ic-http-outcall/proxy-client/Cargo.toml @@ -9,7 +9,7 @@ ic-http-outcall-proxy-canister = { path = "../proxy-canister" } ic-exports = { path = "../../ic-exports" } ic-canister-client = { path = "../../ic-canister-client", features = ["ic-agent-client"] } -tokio = { workspace = true, features = ["rt", "time"] } +tokio = { workspace = true, features = ["rt", "time", "macros"] } anyhow = { workspace = true } futures = { workspace = true } log = { workspace = true } diff --git a/ic-http-outcall/proxy-client/src/main.rs b/ic-http-outcall/proxy-client/src/main.rs index 597bce03..e111aabf 100644 --- a/ic-http-outcall/proxy-client/src/main.rs +++ b/ic-http-outcall/proxy-client/src/main.rs @@ -45,7 +45,7 @@ pub struct ProxyClientArgs { pub batch_size: usize, } -#[tokio::main] +#[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { env_logger::init(); From e631ef993660eba3dd8eae7d0b63935141a1b2a3 Mon Sep 17 00:00:00 2001 From: f3kilo Date: Mon, 17 Mar 2025 11:46:25 +0300 Subject: [PATCH 08/11] proxy canister test --- ic-http-outcall/api/src/proxy_types.rs | 7 ++ ic-http-outcall/proxy-canister/Cargo.toml | 4 +- ic-http-outcall/proxy-canister/src/lib.rs | 82 +++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/ic-http-outcall/api/src/proxy_types.rs b/ic-http-outcall/api/src/proxy_types.rs index 5ef628d2..68e7a6ed 100644 --- a/ic-http-outcall/api/src/proxy_types.rs +++ b/ic-http-outcall/api/src/proxy_types.rs @@ -20,6 +20,13 @@ pub struct InitArgs { )] pub struct RequestId(u64); +impl RequestId { + /// Returns inner representation of the Id. + pub fn inner(&self) -> u64 { + self.0 + } +} + impl From for RequestId { fn from(value: u64) -> Self { Self(value) diff --git a/ic-http-outcall/proxy-canister/Cargo.toml b/ic-http-outcall/proxy-canister/Cargo.toml index 682a903d..13ee41be 100644 --- a/ic-http-outcall/proxy-canister/Cargo.toml +++ b/ic-http-outcall/proxy-canister/Cargo.toml @@ -12,6 +12,8 @@ ic-canister = { path = "../../ic-canister/ic-canister" } ic-exports = { path = "../../ic-exports" } ic-http-outcall-api = { path = "../api", features = ["proxy-api"] } - candid = { workspace = true } serde = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } diff --git a/ic-http-outcall/proxy-canister/src/lib.rs b/ic-http-outcall/proxy-canister/src/lib.rs index 9dcac837..70c71037 100644 --- a/ic-http-outcall/proxy-canister/src/lib.rs +++ b/ic-http-outcall/proxy-canister/src/lib.rs @@ -93,3 +93,85 @@ thread_local! { static ALLOWED_PROXY: OnceCell = const { OnceCell::new() }; static PENDING_REQUESTS: RefCell> = RefCell::default(); } + +#[cfg(test)] +mod tests { + + use candid::{Nat, Principal}; + use ic_canister::{canister_call, register_virtual_responder, Canister}; + use ic_exports::{ + ic_cdk::api::management_canister::http_request::{ + CanisterHttpRequestArgument, HttpMethod, HttpResponse, + }, + ic_kit::MockContext, + }; + use ic_http_outcall_api::{InitArgs, RequestArgs, ResponseResult}; + use tokio::sync::mpsc::channel; + + use crate::HttpProxyCanister; + + #[tokio::test] + async fn http_proxy_canister_works() { + let ctx = MockContext::new().inject(); + + let ctx_canister = Principal::from_slice(&[1, 2, 3, 4, 5]); + ctx.update_id(ctx_canister); + + let (finished_ids_tx, mut finished_ids_rx) = channel(4); + let callback_name = "some_callback"; + register_virtual_responder( + ctx_canister, + callback_name, + move |(response,): (ResponseResult,)| { + assert!(response.result.is_ok()); + + let tx = finished_ids_tx.clone(); + tokio::spawn(async move { tx.send(response.id).await.unwrap() }); + }, + ); + + let mut proxy_canister = HttpProxyCanister::init_instance(); + proxy_canister + .init(InitArgs { + allowed_proxy: ctx_canister, + }) + .await; + + let request = CanisterHttpRequestArgument { + url: "https://example.com/".into(), + method: HttpMethod::GET, + ..Default::default() + }; + let request_args = RequestArgs { + callback_name: callback_name.into(), + request, + }; + let id = canister_call!(proxy_canister.http_outcall(request_args), RequestId) + .await + .unwrap(); + + let mut pending_requests = canister_call!( + proxy_canister.pending_requests(10), + Vec<(RequestId, CanisterHttpRequestArgument)> + ) + .await + .unwrap(); + + let request = pending_requests.remove(0); + assert_eq!(request.0, id); + + let response = ResponseResult { + id, + result: Ok(HttpResponse { + status: Nat::from(200u64), + headers: vec![], + body: vec![], + }), + }; + canister_call!(proxy_canister.finish_requests(vec![response]), ()) + .await + .unwrap(); + + assert_eq!(finished_ids_rx.recv().await.unwrap(), id); + } +} From b7c72e40cc6f8925b2835f3d9d34af1baf577bb7 Mon Sep 17 00:00:00 2001 From: f3kilo Date: Tue, 18 Mar 2025 12:20:45 +0300 Subject: [PATCH 09/11] more tests, just scripts --- ic-http-outcall/proxy-canister/src/lib.rs | 83 +++++++++++++++++++++- ic-http-outcall/proxy-canister/src/main.rs | 8 +++ just/build.just | 8 ++- 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 ic-http-outcall/proxy-canister/src/main.rs diff --git a/ic-http-outcall/proxy-canister/src/lib.rs b/ic-http-outcall/proxy-canister/src/lib.rs index 70c71037..1755010d 100644 --- a/ic-http-outcall/proxy-canister/src/lib.rs +++ b/ic-http-outcall/proxy-canister/src/lib.rs @@ -3,13 +3,16 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use candid::Principal; -use ic_canister::{init, query, update, virtual_canister_call, Canister, PreUpdate}; +use ic_canister::{ + generate_idl, init, query, update, virtual_canister_call, Canister, Idl, PreUpdate, +}; use ic_exports::ic_cdk::api::management_canister::http_request::CanisterHttpRequestArgument; use ic_exports::ic_kit::ic; use ic_http_outcall_api::{InitArgs, RequestArgs, RequestId, ResponseResult}; static IDS_COUNTER: AtomicU64 = AtomicU64::new(0); +/// Canister to perform non-replicated http requests. #[derive(Clone, Canister)] #[canister_no_upgrade_methods] pub struct HttpProxyCanister { @@ -20,6 +23,7 @@ pub struct HttpProxyCanister { impl PreUpdate for HttpProxyCanister {} impl HttpProxyCanister { + /// Canister initialization. #[init] pub async fn init(&mut self, args: InitArgs) { ALLOWED_PROXY.with(move |cell| { @@ -27,6 +31,9 @@ impl HttpProxyCanister { }); } + /// Perform the non-replicated http outcall. + /// + /// The `args.callback_name` will be called, when the a reply will be ready. #[update] pub fn http_outcall(&mut self, args: RequestArgs) -> RequestId { let sender = ic::caller(); @@ -38,6 +45,10 @@ impl HttpProxyCanister { id } + /// Query endpoint to get pending http requests to process. + /// + /// # Errors + /// - A caller must have a permission to perform the query. #[query] pub fn pending_requests(&self, limit: usize) -> Vec<(RequestId, CanisterHttpRequestArgument)> { check_allowed_proxy(ic::caller()); @@ -50,6 +61,10 @@ impl HttpProxyCanister { }) } + /// Finishes + /// + /// # Errors + /// - A caller must have a permission to finish requests. #[update] pub async fn finish_requests(&mut self, responses: Vec) { check_allowed_proxy(ic::caller()); @@ -71,6 +86,11 @@ impl HttpProxyCanister { }); } } + + /// Generate IDL. + pub fn idl() -> Idl { + generate_idl!() + } } fn check_allowed_proxy(proxy: Principal) { @@ -174,4 +194,65 @@ mod tests { assert_eq!(finished_ids_rx.recv().await.unwrap(), id); } + + #[tokio::test] + async fn failed_requests_use_callback() { + let ctx = MockContext::new().inject(); + + let ctx_canister = Principal::from_slice(&[1, 2, 3, 4, 5]); + ctx.update_id(ctx_canister); + + let (finished_ids_tx, mut finished_ids_rx) = channel(4); + let callback_name = "some_callback"; + register_virtual_responder( + ctx_canister, + callback_name, + move |(response,): (ResponseResult,)| { + assert!(response.result.is_err()); + + let tx = finished_ids_tx.clone(); + tokio::spawn(async move { tx.send(response.id).await.unwrap() }); + }, + ); + + let mut proxy_canister = HttpProxyCanister::init_instance(); + proxy_canister + .init(InitArgs { + allowed_proxy: ctx_canister, + }) + .await; + + let request = CanisterHttpRequestArgument { + url: "https://example.com/".into(), + method: HttpMethod::GET, + ..Default::default() + }; + let request_args = RequestArgs { + callback_name: callback_name.into(), + request, + }; + let id = canister_call!(proxy_canister.http_outcall(request_args), RequestId) + .await + .unwrap(); + + let mut pending_requests = canister_call!( + proxy_canister.pending_requests(10), + Vec<(RequestId, CanisterHttpRequestArgument)> + ) + .await + .unwrap(); + + let request = pending_requests.remove(0); + assert_eq!(request.0, id); + + let response = ResponseResult { + id, + result: Err("failed to perform request".into()), + }; + canister_call!(proxy_canister.finish_requests(vec![response]), ()) + .await + .unwrap(); + + assert_eq!(finished_ids_rx.recv().await.unwrap(), id); + } } diff --git a/ic-http-outcall/proxy-canister/src/main.rs b/ic-http-outcall/proxy-canister/src/main.rs new file mode 100644 index 00000000..b67b54b3 --- /dev/null +++ b/ic-http-outcall/proxy-canister/src/main.rs @@ -0,0 +1,8 @@ +use ic_http_outcall_proxy_canister::HttpProxyCanister; + +fn main() { + let canister_e_idl = HttpProxyCanister::idl(); + let idl = candid::pretty::candid::compile(&canister_e_idl.env.env, &Some(canister_e_idl.actor)); + + println!("{}", idl); +} diff --git a/just/build.just b/just/build.just index 6789273e..d6dc046a 100644 --- a/just/build.just +++ b/just/build.just @@ -9,7 +9,7 @@ clean: # Builds all artifacts [group('build')] -build: pre_build build_ic_stable_structures_dummy_canister build_ic_canister_test_canisters build_ic_task_scheduler_dummy_scheduler_canister build_ic_log_test_canister build_ic_payments_test_canister +build: pre_build build_ic_stable_structures_dummy_canister build_ic_canister_test_canisters build_ic_task_scheduler_dummy_scheduler_canister build_ic_log_test_canister build_ic_payments_test_canister build_http_proxy_canister [private] @@ -72,3 +72,9 @@ build_ic_payments_test_canister: cargo build --target wasm32-unknown-unknown --features export-api -p test-payment-canister --release ic-wasm {{WASM_DIR}}/test-payment-canister.wasm -o {{WASM_DIR}}/test-payment-canister.wasm shrink +[private] +build_http_proxy_canister: + cargo run -p ic-http-outcall-proxy-canister --features export-api > {{WASM_DIR}}/ic-http-outcall-proxy-canister.did + cargo build -p ic-http-outcall-proxy-canister --target wasm32-unknown-unknown --features export-api --release + ic-wasm {{WASM_DIR}}/ic-http-outcall-proxy-canister.wasm -o {{WASM_DIR}}/ic-http-outcall-proxy-canister.wasm shrink + From 32a086ea4f61933b8262ef4fb470cdabef569dd8 Mon Sep 17 00:00:00 2001 From: f3kilo Date: Fri, 21 Mar 2025 11:53:26 +0300 Subject: [PATCH 10/11] readme --- ic-http-outcall/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 ic-http-outcall/README.md diff --git a/ic-http-outcall/README.md b/ic-http-outcall/README.md new file mode 100644 index 00000000..2ae98e3b --- /dev/null +++ b/ic-http-outcall/README.md @@ -0,0 +1,20 @@ +# HTTP Outcall Project +========================= + +## Overview + +The HTTP Outcall project provides a set of Rust crates for making HTTP requests from canisters on the Internet Computer. The project includes three main crates: +- `ic-http-outcall-api` - common types. +- `ic-http-outcall-proxy-canister` - canister to process pending HTTP outcalls. +- `ic-http-outcall-proxy-client` - service to fetch pending HTTP requests from `ic-http-outcall-proxy-canister`, execute them, and send results back to the `ic-http-outcall-proxy-canister`. + +## Usage +To use non-replicated HTTP outcalls there should be: +- `ic-http-outcall-proxy-canister` deployed. +- `ic-http-outcall-proxy-client` service running and configured to work with the proxy canister. +- The client agent should be set as allowed proxy on the proxy canister initialization. + + +If some client canister want to perform non-replicated request, it should: +1. Call the `http_outcall` update endpoint of the `ic-http-outcall-proxy-canister`, providing request data and a callback endpoint name. The call will return `RequestId` of the HTTP request. +2. Wait until the `ic-http-outcall-proxy-canister` call the callback endpoint with the given `RequestId`. The response will contain result of the HTTP request. \ No newline at end of file From 0a431ec01df46ac1e15d5eed94b98e3d23046578 Mon Sep 17 00:00:00 2001 From: f3kilo Date: Fri, 21 Mar 2025 11:54:38 +0300 Subject: [PATCH 11/11] typo --- ic-http-outcall/api/src/non_replicated.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ic-http-outcall/api/src/non_replicated.rs b/ic-http-outcall/api/src/non_replicated.rs index fd3cf4d3..ad10af08 100644 --- a/ic-http-outcall/api/src/non_replicated.rs +++ b/ic-http-outcall/api/src/non_replicated.rs @@ -70,7 +70,7 @@ impl NonReplicatedHttpOutcall { (s, callback) } - /// Checks if some requests are expired, and, if so, finishs them with timeout error. + /// Checks if some requests are expired, and, if so, finishes them with timeout error. pub fn check_requests_timeout(&self) { let now = ic::time(); self.requests