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}