Skip to content

Commit 2c7145f

Browse files
committed
Add ic-mock-http-canister-runtime crate
1 parent a479dfe commit 2c7145f

File tree

10 files changed

+933
-1
lines changed

10 files changed

+933
-1
lines changed

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
[workspace]
2-
members = ["canhttp", "examples/http_canister", "ic-canister-runtime"]
2+
members = [
3+
"canhttp",
4+
"examples/http_canister",
5+
"ic-canister-runtime",
6+
"ic-mock-http-canister-runtime",
7+
]
38
resolver = "2"
49

510
[workspace.package]
@@ -14,10 +19,12 @@ readme = "README.md"
1419
assert_matches = "1.5.0"
1520
async-trait = "0.1.88"
1621
candid = { version = "0.10.13" }
22+
canhttp = { path = "canhttp" }
1723
ciborium = "0.2.2"
1824
futures-channel = "0.3.31"
1925
futures-util = "0.3.31"
2026
http = "1.3.1"
27+
ic-canister-runtime = { path = "ic-canister-runtime" }
2128
ic-cdk = "0.18.7"
2229
ic-error-types = "0.2"
2330
ic-management-canister-types = "0.3.3"
@@ -38,6 +45,7 @@ thiserror = "2.0.12"
3845
tokio = "1.44.1"
3946
tower = "0.5.2"
4047
tower-layer = "0.3.3"
48+
url = "2.5.7"
4149

4250
[profile.release]
4351
debug = false
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## Unreleased
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[package]
2+
name = "ic-mock-http-canister-runtime"
3+
version = "0.1.0"
4+
description = "Rust to mock HTTP outcalls with Pocket IC"
5+
license.workspace = true
6+
readme.workspace = true
7+
homepage.workspace = true
8+
authors.workspace = true
9+
edition.workspace = true
10+
include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"]
11+
repository.workspace = true
12+
documentation = "https://docs.rs/mock_http_runtime"
13+
14+
[dependencies]
15+
async-trait = { workspace = true }
16+
candid = { workspace = true }
17+
canhttp = { workspace = true, features = ["json", "http"] }
18+
ic-canister-runtime = { workspace = true }
19+
ic-cdk = { workspace = true }
20+
ic-error-types = { workspace = true }
21+
pocket-ic = { workspace = true }
22+
serde = { workspace = true }
23+
serde_json = { workspace = true }
24+
url = { workspace = true }
25+
26+
[dev-dependencies]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../LICENSE
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../NOTICE
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//! Library to mock HTTP outcalls on the Internet Computer leveraging the [`ic_canister_runtime`] crate's
2+
//! [`Runtime`] trait as well as [`PocketIc`].
3+
4+
#![forbid(unsafe_code)]
5+
#![forbid(missing_docs)]
6+
7+
mod mock;
8+
9+
use async_trait::async_trait;
10+
use candid::{decode_one, encode_args, utils::ArgumentEncoder, CandidType, Principal};
11+
use ic_canister_runtime::{IcError, Runtime};
12+
use ic_cdk::call::{CallFailed, CallRejected};
13+
use ic_error_types::RejectCode;
14+
pub use mock::{
15+
json::{JsonRpcRequestMatcher, JsonRpcResponse},
16+
CanisterHttpReject, CanisterHttpReply, CanisterHttpRequestMatcher, MockHttpOutcall,
17+
MockHttpOutcallBuilder, MockHttpOutcalls, MockHttpOutcallsBuilder,
18+
};
19+
use pocket_ic::{
20+
common::rest::{CanisterHttpRequest, CanisterHttpResponse, MockCanisterHttpResponse},
21+
nonblocking::PocketIc,
22+
RejectResponse,
23+
};
24+
use serde::de::DeserializeOwned;
25+
use std::{
26+
sync::{Arc, Mutex},
27+
time::Duration,
28+
};
29+
30+
const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000;
31+
const MAX_TICKS: usize = 10;
32+
33+
/// [`Runtime`] using [`PocketIc`] to mock HTTP outcalls.
34+
///
35+
/// This runtime allows making calls to canisters through Pocket IC while verifying the HTTP
36+
/// outcalls made and mocking their responses.
37+
pub struct MockHttpRuntime {
38+
env: Arc<PocketIc>,
39+
caller: Principal,
40+
mocks: Mutex<MockHttpOutcalls>,
41+
}
42+
43+
impl MockHttpRuntime {
44+
/// Create a new [`MockHttpRuntime`] with the given [`PocketIc`] and [`MockHttpOutcalls`].
45+
/// All calls to canisters are made using the given caller identity.
46+
pub fn new(env: Arc<PocketIc>, caller: Principal, mocks: impl Into<MockHttpOutcalls>) -> Self {
47+
Self {
48+
env: env.clone(),
49+
caller,
50+
mocks: Mutex::new(mocks.into()),
51+
}
52+
}
53+
}
54+
55+
#[async_trait]
56+
impl Runtime for MockHttpRuntime {
57+
async fn update_call<In, Out>(
58+
&self,
59+
id: Principal,
60+
method: &str,
61+
args: In,
62+
_cycles: u128,
63+
) -> Result<Out, IcError>
64+
where
65+
In: ArgumentEncoder + Send,
66+
Out: CandidType + DeserializeOwned,
67+
{
68+
let message_id = self
69+
.env
70+
.submit_call(
71+
id,
72+
self.caller,
73+
method,
74+
encode_args(args).unwrap_or_else(panic_when_encode_fails),
75+
)
76+
.await
77+
.unwrap();
78+
self.execute_mocks().await;
79+
self.env
80+
.await_call(message_id)
81+
.await
82+
.map(decode_call_response)
83+
.map_err(parse_reject_response)?
84+
}
85+
86+
async fn query_call<In, Out>(
87+
&self,
88+
id: Principal,
89+
method: &str,
90+
args: In,
91+
) -> Result<Out, IcError>
92+
where
93+
In: ArgumentEncoder + Send,
94+
Out: CandidType + DeserializeOwned,
95+
{
96+
self.env
97+
.query_call(
98+
id,
99+
self.caller,
100+
method,
101+
encode_args(args).unwrap_or_else(panic_when_encode_fails),
102+
)
103+
.await
104+
.map(decode_call_response)
105+
.map_err(parse_reject_response)?
106+
}
107+
}
108+
109+
impl MockHttpRuntime {
110+
async fn execute_mocks(&self) {
111+
loop {
112+
let pending_requests = tick_until_http_requests(self.env.as_ref()).await;
113+
if let Some(request) = pending_requests.first() {
114+
let maybe_mock = {
115+
let mut mocks = self.mocks.lock().unwrap();
116+
mocks.pop_matching(request)
117+
};
118+
match maybe_mock {
119+
Some(mock) => {
120+
let mock_response = MockCanisterHttpResponse {
121+
subnet_id: request.subnet_id,
122+
request_id: request.request_id,
123+
response: check_response_size(request, mock.response),
124+
additional_responses: vec![],
125+
};
126+
self.env.mock_canister_http_response(mock_response).await;
127+
}
128+
None => {
129+
panic!("No mocks matching the request: {:?}", request);
130+
}
131+
}
132+
} else {
133+
return;
134+
}
135+
}
136+
}
137+
}
138+
139+
fn check_response_size(
140+
request: &CanisterHttpRequest,
141+
response: CanisterHttpResponse,
142+
) -> CanisterHttpResponse {
143+
if let CanisterHttpResponse::CanisterHttpReply(reply) = &response {
144+
let max_response_bytes = request
145+
.max_response_bytes
146+
.unwrap_or(DEFAULT_MAX_RESPONSE_BYTES);
147+
if reply.body.len() as u64 > max_response_bytes {
148+
// Approximate replica behavior since headers are not accounted for.
149+
return CanisterHttpResponse::CanisterHttpReject(
150+
pocket_ic::common::rest::CanisterHttpReject {
151+
reject_code: RejectCode::SysFatal as u64,
152+
message: format!("Http body exceeds size limit of {max_response_bytes} bytes.",),
153+
},
154+
);
155+
}
156+
}
157+
response
158+
}
159+
160+
fn parse_reject_response(response: RejectResponse) -> IcError {
161+
CallFailed::CallRejected(CallRejected::with_rejection(
162+
response.reject_code as u32,
163+
response.reject_message,
164+
))
165+
.into()
166+
}
167+
168+
fn decode_call_response<Out>(bytes: Vec<u8>) -> Result<Out, IcError>
169+
where
170+
Out: CandidType + DeserializeOwned,
171+
{
172+
decode_one(&bytes).map_err(|e| IcError::CandidDecodeFailed {
173+
message: e.to_string(),
174+
})
175+
}
176+
177+
fn panic_when_encode_fails(err: candid::error::Error) -> Vec<u8> {
178+
panic!("failed to encode args: {err}")
179+
}
180+
181+
async fn tick_until_http_requests(env: &PocketIc) -> Vec<CanisterHttpRequest> {
182+
let mut requests = Vec::new();
183+
for _ in 0..MAX_TICKS {
184+
requests = env.get_canister_http().await;
185+
if !requests.is_empty() {
186+
break;
187+
}
188+
env.tick().await;
189+
env.advance_time(Duration::from_nanos(1)).await;
190+
}
191+
requests
192+
}

0 commit comments

Comments
 (0)