Skip to main content

ic_pocket_canister_runtime/
lib.rs

1//! Library to mock HTTP outcalls on the Internet Computer leveraging the [`ic_canister_runtime`]
2//! crate's [`Runtime`] trait as well as [`PocketIc`].
3
4#![forbid(unsafe_code)]
5#![forbid(missing_docs)]
6
7mod mock;
8
9use async_trait::async_trait;
10use candid::{decode_one, encode_args, utils::ArgumentEncoder, CandidType, Principal};
11use ic_canister_runtime::{IcError, Runtime};
12use ic_cdk::call::{CallFailed, CallRejected};
13use ic_error_types::RejectCode;
14pub use mock::{
15    json::{JsonRpcRequestMatcher, JsonRpcResponse},
16    AnyCanisterHttpRequestMatcher, CanisterHttpReject, CanisterHttpReply,
17    CanisterHttpRequestMatcher, MockHttpOutcall, MockHttpOutcallBuilder, MockHttpOutcalls,
18    MockHttpOutcallsBuilder,
19};
20use pocket_ic::{
21    common::rest::{CanisterHttpRequest, CanisterHttpResponse, MockCanisterHttpResponse},
22    nonblocking::PocketIc,
23    RejectResponse,
24};
25use serde::de::DeserializeOwned;
26use std::time::Duration;
27use tokio::sync::Mutex;
28
29const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000;
30const MAX_TICKS: usize = 10;
31
32/// [`Runtime`] using [`PocketIc`] to make calls to canisters.
33///
34/// # Examples
35/// Call the `make_http_post_request` endpoint on the example [`http_canister`] deployed with
36/// Pocket IC and mock the resulting HTTP outcall.
37/// ```rust, no_run
38/// # #[tokio::main]
39/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
40/// use ic_canister_runtime::Runtime;
41/// use ic_pocket_canister_runtime::{
42///     AnyCanisterHttpRequestMatcher, CanisterHttpReply, MockHttpOutcallsBuilder,
43///     PocketIcRuntime
44/// };
45/// use pocket_ic::nonblocking::PocketIc;
46/// # use candid::Principal;
47///
48/// let mocks = MockHttpOutcallsBuilder::new()
49///     .given(AnyCanisterHttpRequestMatcher)
50///     .respond_with(
51///         CanisterHttpReply::with_status(200)
52///             .with_body(r#"{"data": "Hello, World!", "headers": {"X-Id": "42"}}"#)
53///     );
54///
55/// let pocket_ic = PocketIc::new().await;
56/// let runtime = PocketIcRuntime::new(&pocket_ic, Principal::anonymous())
57///     .with_http_mocks(mocks.build());
58/// # let canister_id = Principal::anonymous();
59///
60/// let http_request_result: String = runtime
61///     .update_call(canister_id, "make_http_post_request", (), 0)
62///     .await
63///     .expect("Call to `http_canister` failed");
64///
65/// assert!(http_request_result.contains("Hello, World!"));
66/// assert!(http_request_result.contains("\"X-Id\": \"42\""));
67/// # Ok(())
68/// # }
69/// ```
70///
71/// [`http_canister`]: https://github.com/dfinity/canhttp/tree/main/examples/http_canister/
72pub struct PocketIcRuntime<'a> {
73    env: &'a PocketIc,
74    caller: Principal,
75    // The mocks are stored in a Mutex<Box<?>> so they can be modified in the implementation of
76    // the `Runtime::update_call` method using interior mutability.
77    // This is necessary since `Runtime::update_call` takes an immutable reference to the runtime.
78    mocks: Option<Mutex<Box<dyn ExecuteHttpOutcallMocks>>>,
79}
80
81impl<'a> PocketIcRuntime<'a> {
82    /// Create a new [`PocketIcRuntime`] with the given [`PocketIc`].
83    /// All calls to canisters are made using the given caller identity.
84    pub fn new(env: &'a PocketIc, caller: Principal) -> Self {
85        Self {
86            env,
87            caller,
88            mocks: None,
89        }
90    }
91
92    /// Mock HTTP outcalls and their responses.
93    ///
94    /// This allows making calls to canisters through Pocket IC while verifying the HTTP outcalls
95    /// made and mocking their responses.
96    ///
97    /// # Examples
98    /// Call the `make_http_post_request` endpoint on the example [`http_canister`] deployed with
99    /// Pocket IC and mock the resulting HTTP outcall.
100    /// ```rust, no_run
101    /// # #[tokio::main]
102    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
103    /// use ic_canister_runtime::Runtime;
104    /// use ic_pocket_canister_runtime::{
105    ///     AnyCanisterHttpRequestMatcher, CanisterHttpReply, MockHttpOutcallsBuilder,
106    ///     PocketIcRuntime
107    /// };
108    /// use pocket_ic::nonblocking::PocketIc;
109    /// # use candid::Principal;
110    ///
111    /// let mocks = MockHttpOutcallsBuilder::new()
112    ///     // Matches any HTTP outcall request
113    ///     .given(AnyCanisterHttpRequestMatcher)
114    ///     // Assert that the HTTP outcall response has the given status code and body
115    ///     .respond_with(
116    ///         CanisterHttpReply::with_status(200)
117    ///             .with_body(r#"{"data": "Hello, World!", "headers": {"X-Id": "42"}}"#)
118    ///     );
119    ///
120    /// let pocket_ic = PocketIc::new().await;
121    /// let runtime = PocketIcRuntime::new(&pocket_ic, Principal::anonymous())
122    ///     .with_http_mocks(mocks.build());
123    /// # let canister_id = Principal::anonymous();
124    ///
125    /// let http_request_result: String = runtime
126    ///     .update_call(canister_id, "make_http_post_request", (), 0)
127    ///     .await
128    ///     .expect("Call to `http_canister` failed");
129    ///
130    /// assert!(http_request_result.contains("Hello, World!"));
131    /// assert!(http_request_result.contains("\"X-Id\": \"42\""));
132    /// # Ok(())
133    /// # }
134    /// ```
135    ///
136    /// [`http_canister`]: https://github.com/dfinity/canhttp/tree/main/examples/http_canister/
137    pub fn with_http_mocks(mut self, mocks: impl ExecuteHttpOutcallMocks + 'static) -> Self {
138        self.mocks = Some(Mutex::new(Box::new(mocks)));
139        self
140    }
141}
142
143#[async_trait]
144impl Runtime for PocketIcRuntime<'_> {
145    async fn update_call<In, Out>(
146        &self,
147        id: Principal,
148        method: &str,
149        args: In,
150        _cycles: u128,
151    ) -> Result<Out, IcError>
152    where
153        In: ArgumentEncoder + Send,
154        Out: CandidType + DeserializeOwned,
155    {
156        let message_id = self
157            .env
158            .submit_call(
159                id,
160                self.caller,
161                method,
162                encode_args(args).unwrap_or_else(panic_when_encode_fails),
163            )
164            .await
165            .map_err(parse_reject_response)?;
166        if let Some(mock) = &self.mocks {
167            mock.try_lock()
168                .unwrap()
169                .execute_http_outcall_mocks(self.env)
170                .await;
171        }
172        if self.env.auto_progress_enabled().await {
173            self.env.await_call_no_ticks(message_id).await
174        } else {
175            self.env.await_call(message_id).await
176        }
177        .map(decode_call_response)
178        .map_err(parse_reject_response)?
179    }
180
181    async fn query_call<In, Out>(
182        &self,
183        id: Principal,
184        method: &str,
185        args: In,
186    ) -> Result<Out, IcError>
187    where
188        In: ArgumentEncoder + Send,
189        Out: CandidType + DeserializeOwned,
190    {
191        self.env
192            .query_call(
193                id,
194                self.caller,
195                method,
196                encode_args(args).unwrap_or_else(panic_when_encode_fails),
197            )
198            .await
199            .map(decode_call_response)
200            .map_err(parse_reject_response)?
201    }
202}
203
204/// Execute HTTP outcall mocks.
205#[async_trait]
206pub trait ExecuteHttpOutcallMocks: Send + Sync {
207    /// Execute HTTP outcall mocks.
208    async fn execute_http_outcall_mocks(&mut self, runtime: &PocketIc) -> ();
209}
210
211#[async_trait]
212impl ExecuteHttpOutcallMocks for MockHttpOutcalls {
213    async fn execute_http_outcall_mocks(&mut self, env: &PocketIc) -> () {
214        loop {
215            let pending_requests = tick_until_http_requests(env).await;
216            if let Some(request) = pending_requests.first() {
217                let maybe_mock = { self.pop_matching(request) };
218                match maybe_mock {
219                    Some(mock) => {
220                        let mock_response = MockCanisterHttpResponse {
221                            subnet_id: request.subnet_id,
222                            request_id: request.request_id,
223                            response: check_response_size(request, mock.response),
224                            additional_responses: vec![],
225                        };
226                        env.mock_canister_http_response(mock_response).await;
227                    }
228                    None => {
229                        panic!("No mocks matching the request: {:?}", request);
230                    }
231                }
232            } else {
233                return;
234            }
235        }
236    }
237}
238
239fn check_response_size(
240    request: &CanisterHttpRequest,
241    response: CanisterHttpResponse,
242) -> CanisterHttpResponse {
243    if let CanisterHttpResponse::CanisterHttpReply(reply) = &response {
244        let max_response_bytes = request
245            .max_response_bytes
246            .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES);
247        if reply.body.len() as u64 > max_response_bytes {
248            // Approximate replica behavior since headers are not accounted for.
249            return CanisterHttpResponse::CanisterHttpReject(
250                pocket_ic::common::rest::CanisterHttpReject {
251                    reject_code: RejectCode::SysFatal as u64,
252                    message: format!("Http body exceeds size limit of {max_response_bytes} bytes.",),
253                },
254            );
255        }
256    }
257    response
258}
259
260fn parse_reject_response(response: RejectResponse) -> IcError {
261    CallFailed::CallRejected(CallRejected::with_rejection(
262        response.reject_code as u32,
263        response.reject_message,
264    ))
265    .into()
266}
267
268fn decode_call_response<Out>(bytes: Vec<u8>) -> Result<Out, IcError>
269where
270    Out: CandidType + DeserializeOwned,
271{
272    decode_one(&bytes).map_err(|e| IcError::CandidDecodeFailed {
273        message: e.to_string(),
274    })
275}
276
277fn panic_when_encode_fails(err: candid::error::Error) -> Vec<u8> {
278    panic!("failed to encode args: {err}")
279}
280
281async fn tick_until_http_requests(env: &PocketIc) -> Vec<CanisterHttpRequest> {
282    let mut requests = Vec::new();
283    for _ in 0..MAX_TICKS {
284        requests = env.get_canister_http().await;
285        if !requests.is_empty() {
286            break;
287        }
288        env.tick().await;
289        env.advance_time(Duration::from_nanos(1)).await;
290    }
291    requests
292}