Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion Cargo.lock

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

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ members = [
"examples/http_canister",
"ic-agent-canister-runtime",
"ic-canister-runtime",
"ic-mock-http-canister-runtime",
"ic-pocket-canister-runtime",
]
resolver = "2"

Expand Down Expand Up @@ -34,6 +34,7 @@ ic-test-utilities-load-wasm = { git = "https://github.com/dfinity/ic", tag = "re
itertools = "0.14.0"
maplit = "1.0.2"
num-traits = "0.2.19"
once_cell = "1.21.3"
pin-project = "1.1.10"
pocket-ic = "9.0.2"
proptest = "1.6.0"
Expand Down
4 changes: 2 additions & 2 deletions ic-canister-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Library to abstract the canister runtime so that code making requests to canisters can be reused, e.g.:
* in production using [`ic_cdk`](https://crates.io/crates/ic-cdk),
* in unit tests by mocking this crate's `Runtime` trait,
* in integration tests by implementing this trait for [PocketIC](https://internetcomputer.org/docs/building-apps/test/pocket-ic) or using the `MockHttpRuntime` implementation from the [`ic-mock-http-canister-runtime`](https://crates.io/crates/ic-mock-http-canister-runtime) crate.
* in integration tests by implementing this trait for [PocketIC](https://internetcomputer.org/docs/building-apps/test/pocket-ic) yourself or using the `PokcetIcRuntime` implementation from the [`ic-pocket-canister-runtime`](https://crates.io/crates/ic-pocket-canister-runtime) crate.

## Usage

Expand Down Expand Up @@ -55,7 +55,7 @@ assert!(http_request_result.contains("Hello, World!"));
assert!(http_request_result.contains("\"X-Id\": \"42\""));
```

See the [Rust documentation](https://docs.rs/ic-canister-runtime) for more details as well as the [`ic-mock-http-canister-runtime`](https://docs.rs/ic-mock-http-canister-runtime) and [`ic-agent-canister-runtime`](https://docs.rs/ic-agent-canister-runtime) crates for some further implementations of the `Runtime` trait.
See the [Rust documentation](https://docs.rs/ic-canister-runtime) for more details as well as the [`ic-pocket-canister-runtime`](https://docs.rs/ic-pocket-canister-runtime) and [`ic-agent-canister-runtime`](https://docs.rs/ic-agent-canister-runtime) crates for some further implementations of the `Runtime` trait.

## Cargo Features

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
[package]
name = "ic-mock-http-canister-runtime"
name = "ic-pocket-canister-runtime"
version = "0.1.0"
description = "Mock HTTP outcalls with Pocket IC"
description = "Canisters runtime on the Internet Computer using Pocket IC"
license.workspace = true
readme.workspace = true
homepage.workspace = true
authors.workspace = true
edition.workspace = true
include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"]
repository.workspace = true
documentation = "https://docs.rs/ic-mock-http-canister-runtime"
documentation = "https://docs.rs/ic-pocket-canister-runtime"

[dependencies]
async-trait = { workspace = true }
Expand All @@ -18,6 +18,7 @@ canhttp = { workspace = true, features = ["json", "http"] }
ic-canister-runtime = { workspace = true }
ic-cdk = { workspace = true }
ic-error-types = { workspace = true }
once_cell = { workspace = true }
pocket-ic = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,28 @@
[![DFinity Forum](https://img.shields.io/badge/help-post%20on%20forum.dfinity.org-blue?style=for-the-badge)](https://forum.dfinity.org/)
[![GitHub license](https://img.shields.io/badge/license-Apache%202.0-blue.svg?logo=apache&style=for-the-badge)](LICENSE)

# `ic-pocket-canister-runtime`

# `ic-mock-http-canister-runtime`

Library to mock [HTTPs outcalls](https://internetcomputer.org/https-outcalls) on the Internet Computer leveraging the [`ic_canister_runtime`](https://crates.io/crates/ic-canister-runtime) crate's `Runtime` trait as well as [PocketIC](https://internetcomputer.org/docs/building-apps/test/pocket-ic).
Implementation of the [`ic_canister_runtime`](https://crates.io/crates/ic-canister-runtime) crate's `Runtime` trait for [PocketIC](https://internetcomputer.org/docs/building-apps/test/pocket-ic) allowing to mock
[HTTPs outcalls](https://internetcomputer.org/https-outcalls).

## Usage

Add this to your `Cargo.toml` (see [crates.io](https://crates.io/crates/ic-mock-http-canister-runtime) for the latest version):
Add this to your `Cargo.toml` (see [crates.io](https://crates.io/crates/ic-pocket-canister-runtime) for the latest version):

```toml
ic-canister-runtime = "0.1.0"
ic-mock-http-canister-runtime = "0.1.0"
ic-pocket-canister-runtime = "0.1.0"
```

Then, use the library to mock HTTP outcalls for canister deployed with PocketIC, as follows:
```rust
use ic_canister_runtime::Runtime;
use ic_mock_http_canister_runtime::{
use ic_pocket_canister_runtime::{
AnyCanisterHttpRequestMatcher, CanisterHttpReply, MockHttpOutcallsBuilder,
MockHttpRuntime
};
use pocket_ic::nonblocking::PocketIc;

let mocks = MockHttpOutcallsBuilder::new()
.given(AnyCanisterHttpRequestMatcher)
Expand All @@ -31,7 +32,9 @@ let mocks = MockHttpOutcallsBuilder::new()
.with_body(r#"{"data": "Hello, World!", "headers": {"X-Id": "42"}}"#)
);

let runtime = MockHttpRuntime::new(pocket_ic, Principal::anonymous(), mocks);
let pocket_ic = PocketIc::new().await;
let runtime = MockHttpRuntime::new(&pocket_ic, Principal::anonymous())
.with_http_mocks(mocks.build());

let http_request_result: String = runtime
.update_call(canister_id, "make_http_post_request", (), 0)
Expand All @@ -42,7 +45,7 @@ assert!(http_request_result.contains("Hello, World!"));
assert!(http_request_result.contains("\"X-Id\": \"42\""));
```

See the [Rust documentation](https://docs.rs/ic-mock-http-canister-runtime) for more details.
See the [Rust documentation](https://docs.rs/ic-pocket-canister-runtime) for more details.

## License

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,19 @@ use pocket_ic::{
RejectResponse,
};
use serde::de::DeserializeOwned;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use std::time::Duration;

const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000;
const MAX_TICKS: usize = 10;

/// [`Runtime`] using [`PocketIc`] to mock HTTP outcalls.
///
/// This runtime allows making calls to canisters through Pocket IC while verifying the HTTP
/// outcalls made and mocking their responses.
#[derive(Default)]
enum PocketIcMode {
Live,
#[default]
NotLive,
}

/// [`Runtime`] using [`PocketIc`] to make calls to canisters.
///
/// # Examples
/// Call the `make_http_post_request` endpoint on the example [`http_canister`] deployed with
Expand All @@ -43,12 +44,11 @@ const MAX_TICKS: usize = 10;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use ic_canister_runtime::Runtime;
/// use ic_mock_http_canister_runtime::{
/// use ic_pocket_canister_runtime::{
/// AnyCanisterHttpRequestMatcher, CanisterHttpReply, MockHttpOutcallsBuilder,
/// MockHttpRuntime
/// PocketIcRuntime
/// };
/// use pocket_ic::nonblocking::PocketIc;
/// use std::sync::Arc;
/// # use candid::Principal;
///
/// let mocks = MockHttpOutcallsBuilder::new()
Expand All @@ -58,8 +58,9 @@ const MAX_TICKS: usize = 10;
/// .with_body(r#"{"data": "Hello, World!", "headers": {"X-Id": "42"}}"#)
/// );
///
/// let pocket_ic = Arc::new(PocketIc::new().await);
/// let runtime = MockHttpRuntime::new(pocket_ic, Principal::anonymous(), mocks);
/// let pocket_ic = PocketIc::new().await;
/// let runtime = PocketIcRuntime::new(&pocket_ic, Principal::anonymous())
/// .with_http_mocks(mocks.build());
/// # let canister_id = Principal::anonymous();
///
/// let http_request_result: String = runtime
Expand All @@ -74,26 +75,87 @@ const MAX_TICKS: usize = 10;
/// ```
///
/// [`http_canister`]: https://github.com/dfinity/canhttp/tree/main/examples/http_canister/
pub struct MockHttpRuntime {
env: Arc<PocketIc>,
pub struct PocketIcRuntime<'a> {
env: &'a PocketIc,
caller: Principal,
mocks: Mutex<MockHttpOutcalls>,
mocks: Option<Box<dyn ExecuteHttpOutcallMocks>>,
mode: PocketIcMode,
}

impl MockHttpRuntime {
/// Create a new [`MockHttpRuntime`] with the given [`PocketIc`] and [`MockHttpOutcalls`].
impl<'a> PocketIcRuntime<'a> {
/// Create a new [`PocketIcRuntime`] with the given [`PocketIc`].
/// All calls to canisters are made using the given caller identity.
pub fn new(env: Arc<PocketIc>, caller: Principal, mocks: impl Into<MockHttpOutcalls>) -> Self {
pub fn new(env: &'a PocketIc, caller: Principal) -> Self {
Self {
env,
caller,
mocks: Mutex::new(mocks.into()),
mocks: None,
mode: Default::default(),
}
}

/// Mock HTTP outcalls and their responses.
///
/// This allows making calls to canisters through Pocket IC while verifying the HTTP outcalls
/// made and mocking their responses.
///
/// # Examples
/// Call the `make_http_post_request` endpoint on the example [`http_canister`] deployed with
/// Pocket IC and mock the resulting HTTP outcall.
/// ```rust, no_run
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use ic_canister_runtime::Runtime;
/// use ic_pocket_canister_runtime::{
/// AnyCanisterHttpRequestMatcher, CanisterHttpReply, MockHttpOutcallsBuilder,
/// PocketIcRuntime
/// };
/// use pocket_ic::nonblocking::PocketIc;
/// # use candid::Principal;
///
/// let mocks = MockHttpOutcallsBuilder::new()
/// // Matches any HTTP outcall request
/// .given(AnyCanisterHttpRequestMatcher)
/// // Assert that the HTTP outcall response has the given status code and body
/// .respond_with(
/// CanisterHttpReply::with_status(200)
/// .with_body(r#"{"data": "Hello, World!", "headers": {"X-Id": "42"}}"#)
/// );
///
/// let pocket_ic = PocketIc::new().await;
/// let runtime = PocketIcRuntime::new(&pocket_ic, Principal::anonymous())
/// .with_http_mocks(mocks.build());
/// # let canister_id = Principal::anonymous();
///
/// let http_request_result: String = runtime
/// .update_call(canister_id, "make_http_post_request", (), 0)
/// .await
/// .expect("Call to `http_canister` failed");
///
/// assert!(http_request_result.contains("Hello, World!"));
/// assert!(http_request_result.contains("\"X-Id\": \"42\""));
/// # Ok(())
/// # }
/// ```
///
/// [`http_canister`]: https://github.com/dfinity/canhttp/tree/main/examples/http_canister/
pub fn with_http_mocks(mut self, mocks: impl ExecuteHttpOutcallMocks + 'static) -> Self {
self.mocks = Some(Box::new(mocks));
self
}

/// Use Pocket IC in [live mode](https://github.com/dfinity/ic/blob/f0c82237ae16745ac54dd3838b3f91ce32a6bc52/packages/pocket-ic/HOWTO.md?plain=1#L43).
Comment thread
lpahlavi marked this conversation as resolved.
Outdated
///
/// The pocket IC instance will automatically progress and execute HTTPs outcalls.
/// This setting renders the tests non-deterministic.
pub fn live_mode(mut self) -> Self {
self.mode = PocketIcMode::Live;
self
}
}

#[async_trait]
impl Runtime for MockHttpRuntime {
impl Runtime for PocketIcRuntime<'_> {
async fn update_call<In, Out>(
&self,
id: Principal,
Expand All @@ -114,13 +176,16 @@ impl Runtime for MockHttpRuntime {
encode_args(args).unwrap_or_else(panic_when_encode_fails),
)
.await
.unwrap();
self.execute_mocks().await;
self.env
.await_call(message_id)
.await
.map(decode_call_response)
.map_err(parse_reject_response)?
.map_err(parse_reject_response)?;
if let Some(mock) = &self.mocks {
mock.execute_http_outcall_mocks(self.env).await;
}
(match self.mode {
PocketIcMode::NotLive => self.env.await_call(message_id).await,
PocketIcMode::Live => self.env.await_call_no_ticks(message_id).await,
})
.map(decode_call_response)
.map_err(parse_reject_response)?
}

async fn query_call<In, Out>(
Expand All @@ -146,15 +211,20 @@ impl Runtime for MockHttpRuntime {
}
}

impl MockHttpRuntime {
async fn execute_mocks(&self) {
/// Execute HTTP outcall mocks.
#[async_trait]
pub trait ExecuteHttpOutcallMocks: Send + Sync {
/// Execute HTTP outcall mocks.
async fn execute_http_outcall_mocks(&self, runtime: &PocketIc) -> ();
Comment thread
lpahlavi marked this conversation as resolved.
Outdated
}

#[async_trait]
impl ExecuteHttpOutcallMocks for MockHttpOutcalls {
async fn execute_http_outcall_mocks(&self, env: &PocketIc) -> () {
loop {
let pending_requests = tick_until_http_requests(self.env.as_ref()).await;
let pending_requests = tick_until_http_requests(env).await;
if let Some(request) = pending_requests.first() {
let maybe_mock = {
let mut mocks = self.mocks.lock().unwrap();
mocks.pop_matching(request)
};
let maybe_mock = { self.pop_matching(request) };
match maybe_mock {
Some(mock) => {
let mock_response = MockCanisterHttpResponse {
Expand All @@ -163,7 +233,7 @@ impl MockHttpRuntime {
response: check_response_size(request, mock.response),
additional_responses: vec![],
};
self.env.mock_canister_http_response(mock_response).await;
env.mock_canister_http_response(mock_response).await;
}
None => {
panic!("No mocks matching the request: {:?}", request);
Expand Down
Loading