From 2c7145fca0b87635131fc473bc7140ec743aa1a5 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 5 Nov 2025 13:20:40 +0100 Subject: [PATCH 1/2] Add `ic-mock-http-canister-runtime` crate --- Cargo.lock | 16 + Cargo.toml | 10 +- ic-mock-http-canister-runtime/CHANGELOG.md | 8 + ic-mock-http-canister-runtime/Cargo.toml | 26 ++ ic-mock-http-canister-runtime/LICENSE | 1 + ic-mock-http-canister-runtime/NOTICE | 1 + ic-mock-http-canister-runtime/src/lib.rs | 192 +++++++++++ .../src/mock/json/mod.rs | 230 +++++++++++++ .../src/mock/json/tests.rs | 138 ++++++++ ic-mock-http-canister-runtime/src/mock/mod.rs | 312 ++++++++++++++++++ 10 files changed, 933 insertions(+), 1 deletion(-) create mode 100644 ic-mock-http-canister-runtime/CHANGELOG.md create mode 100644 ic-mock-http-canister-runtime/Cargo.toml create mode 120000 ic-mock-http-canister-runtime/LICENSE create mode 120000 ic-mock-http-canister-runtime/NOTICE create mode 100644 ic-mock-http-canister-runtime/src/lib.rs create mode 100644 ic-mock-http-canister-runtime/src/mock/json/mod.rs create mode 100644 ic-mock-http-canister-runtime/src/mock/json/tests.rs create mode 100644 ic-mock-http-canister-runtime/src/mock/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 9a6f707..5ce20cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,6 +912,22 @@ dependencies = [ "serde_bytes", ] +[[package]] +name = "ic-mock-http-canister-runtime" +version = "0.1.0" +dependencies = [ + "async-trait", + "candid", + "canhttp", + "ic-canister-runtime", + "ic-cdk", + "ic-error-types", + "pocket-ic", + "serde", + "serde_json", + "url", +] + [[package]] name = "ic-test-utilities-load-wasm" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index f5a7bda..04ab532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] -members = ["canhttp", "examples/http_canister", "ic-canister-runtime"] +members = [ + "canhttp", + "examples/http_canister", + "ic-canister-runtime", + "ic-mock-http-canister-runtime", +] resolver = "2" [workspace.package] @@ -14,10 +19,12 @@ readme = "README.md" assert_matches = "1.5.0" async-trait = "0.1.88" candid = { version = "0.10.13" } +canhttp = { path = "canhttp" } ciborium = "0.2.2" futures-channel = "0.3.31" futures-util = "0.3.31" http = "1.3.1" +ic-canister-runtime = { path = "ic-canister-runtime" } ic-cdk = "0.18.7" ic-error-types = "0.2" ic-management-canister-types = "0.3.3" @@ -38,6 +45,7 @@ thiserror = "2.0.12" tokio = "1.44.1" tower = "0.5.2" tower-layer = "0.3.3" +url = "2.5.7" [profile.release] debug = false diff --git a/ic-mock-http-canister-runtime/CHANGELOG.md b/ic-mock-http-canister-runtime/CHANGELOG.md new file mode 100644 index 0000000..b7e3f52 --- /dev/null +++ b/ic-mock-http-canister-runtime/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased \ No newline at end of file diff --git a/ic-mock-http-canister-runtime/Cargo.toml b/ic-mock-http-canister-runtime/Cargo.toml new file mode 100644 index 0000000..b0dc103 --- /dev/null +++ b/ic-mock-http-canister-runtime/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ic-mock-http-canister-runtime" +version = "0.1.0" +description = "Rust to mock HTTP outcalls with 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/mock_http_runtime" + +[dependencies] +async-trait = { workspace = true } +candid = { workspace = true } +canhttp = { workspace = true, features = ["json", "http"] } +ic-canister-runtime = { workspace = true } +ic-cdk = { workspace = true } +ic-error-types = { workspace = true } +pocket-ic = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +url = { workspace = true } + +[dev-dependencies] diff --git a/ic-mock-http-canister-runtime/LICENSE b/ic-mock-http-canister-runtime/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/ic-mock-http-canister-runtime/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/ic-mock-http-canister-runtime/NOTICE b/ic-mock-http-canister-runtime/NOTICE new file mode 120000 index 0000000..7e1b82f --- /dev/null +++ b/ic-mock-http-canister-runtime/NOTICE @@ -0,0 +1 @@ +../NOTICE \ No newline at end of file diff --git a/ic-mock-http-canister-runtime/src/lib.rs b/ic-mock-http-canister-runtime/src/lib.rs new file mode 100644 index 0000000..0cd4062 --- /dev/null +++ b/ic-mock-http-canister-runtime/src/lib.rs @@ -0,0 +1,192 @@ +//! Library to mock HTTP outcalls on the Internet Computer leveraging the [`ic_canister_runtime`] crate's +//! [`Runtime`] trait as well as [`PocketIc`]. + +#![forbid(unsafe_code)] +#![forbid(missing_docs)] + +mod mock; + +use async_trait::async_trait; +use candid::{decode_one, encode_args, utils::ArgumentEncoder, CandidType, Principal}; +use ic_canister_runtime::{IcError, Runtime}; +use ic_cdk::call::{CallFailed, CallRejected}; +use ic_error_types::RejectCode; +pub use mock::{ + json::{JsonRpcRequestMatcher, JsonRpcResponse}, + CanisterHttpReject, CanisterHttpReply, CanisterHttpRequestMatcher, MockHttpOutcall, + MockHttpOutcallBuilder, MockHttpOutcalls, MockHttpOutcallsBuilder, +}; +use pocket_ic::{ + common::rest::{CanisterHttpRequest, CanisterHttpResponse, MockCanisterHttpResponse}, + nonblocking::PocketIc, + RejectResponse, +}; +use serde::de::DeserializeOwned; +use std::{ + sync::{Arc, Mutex}, + 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. +pub struct MockHttpRuntime { + env: Arc, + caller: Principal, + mocks: Mutex, +} + +impl MockHttpRuntime { + /// Create a new [`MockHttpRuntime`] with the given [`PocketIc`] and [`MockHttpOutcalls`]. + /// All calls to canisters are made using the given caller identity. + pub fn new(env: Arc, caller: Principal, mocks: impl Into) -> Self { + Self { + env: env.clone(), + caller, + mocks: Mutex::new(mocks.into()), + } + } +} + +#[async_trait] +impl Runtime for MockHttpRuntime { + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + _cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + let message_id = self + .env + .submit_call( + id, + self.caller, + method, + 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)? + } + + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + self.env + .query_call( + id, + self.caller, + method, + encode_args(args).unwrap_or_else(panic_when_encode_fails), + ) + .await + .map(decode_call_response) + .map_err(parse_reject_response)? + } +} + +impl MockHttpRuntime { + async fn execute_mocks(&self) { + loop { + let pending_requests = tick_until_http_requests(self.env.as_ref()).await; + if let Some(request) = pending_requests.first() { + let maybe_mock = { + let mut mocks = self.mocks.lock().unwrap(); + mocks.pop_matching(request) + }; + match maybe_mock { + Some(mock) => { + let mock_response = MockCanisterHttpResponse { + subnet_id: request.subnet_id, + request_id: request.request_id, + response: check_response_size(request, mock.response), + additional_responses: vec![], + }; + self.env.mock_canister_http_response(mock_response).await; + } + None => { + panic!("No mocks matching the request: {:?}", request); + } + } + } else { + return; + } + } + } +} + +fn check_response_size( + request: &CanisterHttpRequest, + response: CanisterHttpResponse, +) -> CanisterHttpResponse { + if let CanisterHttpResponse::CanisterHttpReply(reply) = &response { + let max_response_bytes = request + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); + if reply.body.len() as u64 > max_response_bytes { + // Approximate replica behavior since headers are not accounted for. + return CanisterHttpResponse::CanisterHttpReject( + pocket_ic::common::rest::CanisterHttpReject { + reject_code: RejectCode::SysFatal as u64, + message: format!("Http body exceeds size limit of {max_response_bytes} bytes.",), + }, + ); + } + } + response +} + +fn parse_reject_response(response: RejectResponse) -> IcError { + CallFailed::CallRejected(CallRejected::with_rejection( + response.reject_code as u32, + response.reject_message, + )) + .into() +} + +fn decode_call_response(bytes: Vec) -> Result +where + Out: CandidType + DeserializeOwned, +{ + decode_one(&bytes).map_err(|e| IcError::CandidDecodeFailed { + message: e.to_string(), + }) +} + +fn panic_when_encode_fails(err: candid::error::Error) -> Vec { + panic!("failed to encode args: {err}") +} + +async fn tick_until_http_requests(env: &PocketIc) -> Vec { + let mut requests = Vec::new(); + for _ in 0..MAX_TICKS { + requests = env.get_canister_http().await; + if !requests.is_empty() { + break; + } + env.tick().await; + env.advance_time(Duration::from_nanos(1)).await; + } + requests +} diff --git a/ic-mock-http-canister-runtime/src/mock/json/mod.rs b/ic-mock-http-canister-runtime/src/mock/json/mod.rs new file mode 100644 index 0000000..91999cc --- /dev/null +++ b/ic-mock-http-canister-runtime/src/mock/json/mod.rs @@ -0,0 +1,230 @@ +#[cfg(test)] +mod tests; + +use crate::mock::CanisterHttpRequestMatcher; +use canhttp::http::json::{ConstantSizeId, Id, JsonRpcRequest}; +use pocket_ic::common::rest::{ + CanisterHttpHeader, CanisterHttpMethod, CanisterHttpReply, CanisterHttpRequest, + CanisterHttpResponse, +}; +use serde_json::Value; +use std::{collections::BTreeSet, str::FromStr}; +use url::{Host, Url}; + +/// Matches [`CanisterHttpRequest`]s whose body is a JSON-RPC request. +#[derive(Clone, Debug)] +pub struct JsonRpcRequestMatcher { + method: String, + id: Option, + params: Option, + url: Option, + host: Option, + request_headers: Option>, + max_response_bytes: Option, +} + +impl JsonRpcRequestMatcher { + /// Create a [`JsonRpcRequestMatcher`] that matches only JSON-RPC requests with the given method. + pub fn with_method(method: impl Into) -> Self { + Self { + method: method.into(), + id: None, + params: None, + url: None, + host: None, + request_headers: None, + max_response_bytes: None, + } + } + + /// Mutates the [`JsonRpcRequestMatcher`] to match only requests whose JSON-RPC request ID is a + /// [`ConstantSizeId`] with the given value. + pub fn with_id(self, id: u64) -> Self { + self.with_raw_id(Id::from(ConstantSizeId::from(id))) + } + + /// Mutates the [`JsonRpcRequestMatcher`] to match only requests whose JSON-RPC request ID is an + /// [`Id`] with the given value. + pub fn with_raw_id(self, id: Id) -> Self { + Self { + id: Some(id), + ..self + } + } + + /// Mutates the [`JsonRpcRequestMatcher`] to match only requests with the given JSON-RPC request + /// parameters. + pub fn with_params(self, params: impl Into) -> Self { + Self { + params: Some(params.into()), + ..self + } + } + + /// Mutates the [`JsonRpcRequestMatcher`] to match only requests with the given [URL]. + /// + /// [URL]: https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request + pub fn with_url(self, url: &str) -> Self { + Self { + url: Some(Url::parse(url).expect("BUG: invalid URL")), + ..self + } + } + + /// Mutates the [`JsonRpcRequestMatcher`] to match only requests whose [URL] has the given host. + /// + /// [URL]: https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request + pub fn with_host(self, host: &str) -> Self { + Self { + host: Some(Host::parse(host).expect("BUG: invalid host for a URL")), + ..self + } + } + + /// Mutates the [`JsonRpcRequestMatcher`] to match requests with the given HTTP headers. + pub fn with_request_headers(self, headers: Vec<(impl ToString, impl ToString)>) -> Self { + Self { + request_headers: Some( + headers + .into_iter() + .map(|(name, value)| CanisterHttpHeader { + name: name.to_string(), + value: value.to_string(), + }) + .collect(), + ), + ..self + } + } + + /// Mutates the [`JsonRpcRequestMatcher`] to match requests with the given + /// [`max_response_bytes`]. + /// + /// [`max_response_bytes`]: https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request + pub fn with_max_response_bytes(self, max_response_bytes: impl Into) -> Self { + Self { + max_response_bytes: Some(max_response_bytes.into()), + ..self + } + } +} + +impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { + fn matches(&self, request: &CanisterHttpRequest) -> bool { + let req_url = Url::from_str(&request.url).expect("BUG: invalid URL"); + if let Some(ref mock_url) = self.url { + if mock_url != &req_url { + return false; + } + } + if let Some(ref host) = self.host { + match req_url.host() { + Some(ref req_host) if req_host == host => {} + _ => return false, + } + } + if CanisterHttpMethod::POST != request.http_method { + return false; + } + if let Some(ref headers) = self.request_headers { + fn lower_case_header_name( + CanisterHttpHeader { name, value }: &CanisterHttpHeader, + ) -> CanisterHttpHeader { + CanisterHttpHeader { + name: name.to_lowercase(), + value: value.clone(), + } + } + let expected: BTreeSet<_> = headers.iter().map(lower_case_header_name).collect(); + let actual: BTreeSet<_> = request.headers.iter().map(lower_case_header_name).collect(); + if expected != actual { + return false; + } + } + match serde_json::from_slice::>(&request.body) { + Ok(actual_body) => { + if self.method != actual_body.method() { + return false; + } + if let Some(ref id) = self.id { + if id != actual_body.id() { + return false; + } + } + if let Some(ref params) = self.params { + if Some(params) != actual_body.params() { + return false; + } + } + } + // Not a JSON-RPC request + Err(_) => return false, + } + if let Some(max_response_bytes) = self.max_response_bytes { + if Some(max_response_bytes) != request.max_response_bytes { + return false; + } + } + true + } +} + +/// A mocked JSON-RPC HTTP outcall response. +#[derive(Clone)] +pub struct JsonRpcResponse { + status: u16, + headers: Vec, + body: Value, +} + +impl From for JsonRpcResponse { + fn from(body: Value) -> Self { + Self { + status: 200, + headers: vec![], + body, + } + } +} + +impl JsonRpcResponse { + /// Mutates the response to set the given JSON-RPC response ID to a [`ConstantSizeId`] with the + /// given value. + pub fn with_id(self, id: u64) -> JsonRpcResponse { + self.with_raw_id(Id::from(ConstantSizeId::from(id))) + } + + /// Mutates the response to set the given JSON-RPC response ID to the given [`Id`]. + pub fn with_raw_id(mut self, id: Id) -> JsonRpcResponse { + self.body["id"] = serde_json::to_value(id).expect("BUG: cannot serialize ID"); + self + } +} + +impl From<&Value> for JsonRpcResponse { + fn from(body: &Value) -> Self { + Self::from(body.clone()) + } +} + +impl From for JsonRpcResponse { + fn from(body: String) -> Self { + Self::from(Value::from_str(&body).expect("BUG: invalid JSON-RPC response")) + } +} + +impl From<&str> for JsonRpcResponse { + fn from(body: &str) -> Self { + Self::from(body.to_string()) + } +} + +impl From for CanisterHttpResponse { + fn from(response: JsonRpcResponse) -> Self { + CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply { + status: response.status, + headers: response.headers, + body: serde_json::to_vec(&response.body).unwrap(), + }) + } +} diff --git a/ic-mock-http-canister-runtime/src/mock/json/tests.rs b/ic-mock-http-canister-runtime/src/mock/json/tests.rs new file mode 100644 index 0000000..fc40b31 --- /dev/null +++ b/ic-mock-http-canister-runtime/src/mock/json/tests.rs @@ -0,0 +1,138 @@ +use crate::mock::{json::JsonRpcRequestMatcher, CanisterHttpRequestMatcher}; +use candid::Principal; +use canhttp::http::json::{ConstantSizeId, Id}; +use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpMethod, CanisterHttpRequest}; +use serde_json::{json, Value}; +use std::string::ToString; + +const SUBNET_ID: Principal = Principal::from_slice(&[0, 0, 0, 0, 2, 48, 0, 204, 1, 1]); +const DEFAULT_HOST: &str = "cloudflare-eth.com"; +const DEFAULT_URL: &str = "https://cloudflare-eth.com"; +const DEFAULT_RPC_METHOD: &str = "eth_gasPrice"; +const DEFAULT_RPC_ID: u64 = 1234; +const DEFAULT_RPC_PARAMS: Value = Value::Array(vec![]); +const DEFAULT_MAX_RESPONSE_BYTES: u64 = 1024; +const CONTENT_TYPE_HEADER_LOWERCASE: &str = "content-type"; +const CONTENT_TYPE_VALUE: &str = "application/json"; + +mod json_rpc_request_matcher_tests { + use super::*; + + #[test] + fn should_match_request() { + assert!(request_matcher().matches(&request())); + } + + #[test] + fn should_not_match_wrong_method() { + assert!(!JsonRpcRequestMatcher::with_method("eth_getLogs") + .with_id(DEFAULT_RPC_ID) + .with_params(DEFAULT_RPC_PARAMS) + .matches(&request())); + } + + #[test] + fn should_not_match_wrong_id() { + assert!(!request_matcher().with_raw_id(Id::Null).matches(&request())); + } + + #[test] + fn should_not_match_wrong_params() { + assert!(!request_matcher() + .with_params(Value::Null) + .matches(&request())); + } + + #[test] + fn should_match_url() { + assert!(request_matcher().with_url(DEFAULT_URL).matches(&request())); + } + + #[test] + fn should_not_match_wrong_url() { + assert!(!request_matcher() + .with_url("https://rpc.ankr.com") + .matches(&request())); + } + + #[test] + fn should_match_host() { + assert!(request_matcher() + .with_host(DEFAULT_HOST) + .matches(&request())); + } + + #[test] + fn should_not_match_wrong_host() { + assert!(!request_matcher() + .with_host("rpc.ankr.com") + .matches(&request())); + } + + #[test] + fn should_match_request_headers() { + assert!(request_matcher() + .with_request_headers(vec![(CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE),]) + .matches(&request())); + } + + #[test] + fn should_not_match_wrong_request_headers() { + assert!(!request_matcher() + .with_request_headers(vec![(CONTENT_TYPE_HEADER_LOWERCASE, "text/html"),]) + .matches(&request())); + } + + #[test] + fn should_match_max_response_bytes() { + assert!(request_matcher() + .with_max_response_bytes(DEFAULT_MAX_RESPONSE_BYTES) + .matches(&request())); + } + + #[test] + fn should_not_match_wrong_max_response_bytes() { + assert!(!request_matcher() + .with_max_response_bytes(0_u64) + .matches(&request())); + } + + #[test] + fn should_match_all() { + assert!(JsonRpcRequestMatcher::with_method(DEFAULT_RPC_METHOD) + .with_id(DEFAULT_RPC_ID) + .with_params(DEFAULT_RPC_PARAMS) + .with_url(DEFAULT_URL) + .with_host(DEFAULT_HOST) + .with_max_response_bytes(DEFAULT_MAX_RESPONSE_BYTES) + .with_request_headers(vec![(CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE),]) + .matches(&request())); + } +} + +fn request_matcher() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method(DEFAULT_RPC_METHOD) + .with_id(DEFAULT_RPC_ID) + .with_params(DEFAULT_RPC_PARAMS) +} + +fn request() -> CanisterHttpRequest { + CanisterHttpRequest { + subnet_id: SUBNET_ID, + request_id: 0, + http_method: CanisterHttpMethod::POST, + url: DEFAULT_URL.to_string(), + headers: vec![CanisterHttpHeader { + name: CONTENT_TYPE_HEADER_LOWERCASE.to_string(), + value: CONTENT_TYPE_VALUE.to_string(), + }], + body: serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "method": DEFAULT_RPC_METHOD, + "id": ConstantSizeId::from(DEFAULT_RPC_ID).to_string(), + "params": DEFAULT_RPC_PARAMS, + })) + .unwrap(), + max_response_bytes: Some(DEFAULT_MAX_RESPONSE_BYTES), + } +} diff --git a/ic-mock-http-canister-runtime/src/mock/mod.rs b/ic-mock-http-canister-runtime/src/mock/mod.rs new file mode 100644 index 0000000..5286257 --- /dev/null +++ b/ic-mock-http-canister-runtime/src/mock/mod.rs @@ -0,0 +1,312 @@ +use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpRequest, CanisterHttpResponse}; +use serde_json::Value; +use std::fmt::Debug; + +pub mod json; + +/// A collection of HTTP outcall mocks. +/// +/// When an instance of [`MockHttpOutcalls`] is dropped, it panics if not all mocks were +/// consumed (i.e., if it is not empty). +#[derive(Debug, Default)] +pub struct MockHttpOutcalls(Vec); + +impl MockHttpOutcalls { + /// Asserts that no HTTP outcalls are performed. + pub const NEVER: MockHttpOutcalls = Self(Vec::new()); + + /// Add a new mocked HTTP outcall. + pub fn push(&mut self, mock: MockHttpOutcall) { + self.0.push(mock); + } + + /// Returns a matching [`MockHttpOutcall`] for the given request if there is one, otherwise + /// [`None`]. + /// Panics if there are more than one matching [`MockHttpOutcall`]s for the given request. + pub fn pop_matching(&mut self, request: &CanisterHttpRequest) -> Option { + let matching_positions = self + .0 + .iter() + .enumerate() + .filter_map(|(i, mock)| { + if mock.request.matches(request) { + Some(i) + } else { + None + } + }) + .collect::>(); + + match matching_positions.len() { + 0 => None, + 1 => Some(self.0.swap_remove(matching_positions[0])), + _ => panic!("Multiple mocks match the request: {:?}", request), + } + } +} + +impl Drop for MockHttpOutcalls { + fn drop(&mut self) { + if !self.0.is_empty() { + panic!( + "MockHttpOutcalls dropped but {} mocks were not consumed: {:?}", + self.0.len(), + self.0 + ); + } + } +} + +#[derive(Debug)] +#[must_use] +/// A mocked HTTP outcall with a mocked canister response and a [`CanisterHttpRequestMatcher`] to +/// find matching requests. +pub struct MockHttpOutcall { + /// The matcher to find matching requests. + pub request: Box, + /// The mocked canister response. + pub response: CanisterHttpResponse, +} + +/// A [`MockHttpOutcallsBuilder`] to create a [`MockHttpOutcalls`] with a fluent API. +#[derive(Debug, Default)] +pub struct MockHttpOutcallsBuilder(MockHttpOutcalls); + +impl MockHttpOutcallsBuilder { + /// Create a new empty [`MockHttpOutcallsBuilder`]. + pub fn new() -> Self { + Self::default() + } + + /// Used with [`respond_with`] to add a new mock. + /// + /// # Examples + /// + /// ```rust + /// use ic_mock_http_canister_runtime::{ + /// CanisterHttpReply, JsonRpcRequestMatcher, MockHttpOutcallsBuilder + /// }; + /// + /// # let builder = + /// MockHttpOutcallsBuilder::new() + /// .given(JsonRpcRequestMatcher::with_method("eth_getLogs")) + /// .respond_with(CanisterHttpReply::with_status(403)); + /// # use candid::Principal; + /// # use pocket_ic::common::rest::{CanisterHttpMethod, CanisterHttpRequest}; + /// # use serde_json::json; + /// # let request = CanisterHttpRequest { + /// # subnet_id: Principal::anonymous(), + /// # request_id: 0, + /// # http_method: CanisterHttpMethod::POST, + /// # url: "https://ethereum.publicnode.com/".to_string(), + /// # headers: vec![], + /// # body: serde_json::to_vec(&json!({"jsonrpc": "2.0", "method": "eth_getLogs", "id": 1})).unwrap(), + /// # max_response_bytes: None, + /// # }; + /// # builder.build().pop_matching(&request); + /// ``` + /// + /// [`respond_with`]: MockHttpOutcallBuilder::respond_with + pub fn given( + self, + request: impl CanisterHttpRequestMatcher + 'static, + ) -> MockHttpOutcallBuilder { + MockHttpOutcallBuilder { + parent: self, + request: Box::new(request), + } + } + + /// Creates a [`MockHttpOutcalls`] from [`MockHttpOutcallBuilder`]. + pub fn build(self) -> MockHttpOutcalls { + self.0 + } +} + +impl From for MockHttpOutcalls { + fn from(builder: MockHttpOutcallsBuilder) -> Self { + builder.build() + } +} + +/// The result of calling [`MockHttpOutcallsBuilder::given`], used to add a new mock to a +/// [`MockHttpOutcallsBuilder`]. +/// See the [`respond_with`] method. +/// +/// [`respond_with`]: MockHttpOutcallBuilder::respond_with +#[must_use] +pub struct MockHttpOutcallBuilder { + parent: MockHttpOutcallsBuilder, + request: Box, +} + +impl MockHttpOutcallBuilder { + /// Used with [`given`] to add a new mock. + /// + /// # Examples + /// + /// ```rust + /// use ic_mock_http_canister_runtime::{ + /// CanisterHttpReply, JsonRpcRequestMatcher, MockHttpOutcallsBuilder + /// }; + /// + /// # let builder = + /// MockHttpOutcallsBuilder::new() + /// .given(JsonRpcRequestMatcher::with_method("eth_getLogs")) + /// .respond_with(CanisterHttpReply::with_status(403)); + /// # use candid::Principal; + /// # use pocket_ic::common::rest::{CanisterHttpMethod, CanisterHttpRequest}; + /// # use serde_json::json; + /// # let request = CanisterHttpRequest { + /// # subnet_id: Principal::anonymous(), + /// # request_id: 0, + /// # http_method: CanisterHttpMethod::POST, + /// # url: "https://ethereum.publicnode.com/".to_string(), + /// # headers: vec![], + /// # body: serde_json::to_vec(&json!({"jsonrpc": "2.0", "method": "eth_getLogs", "id": 1})).unwrap(), + /// # max_response_bytes: None, + /// # }; + /// # builder.build().pop_matching(&request); + /// ``` + /// + /// [`given`]: MockHttpOutcallsBuilder::given + pub fn respond_with( + mut self, + response: impl Into, + ) -> MockHttpOutcallsBuilder { + self.parent.0.push(MockHttpOutcall { + request: self.request, + response: response.into(), + }); + self.parent + } +} + +/// A trait that allows checking if a given [`CanisterHttpRequest`] matches an HTTP outcall mock. +pub trait CanisterHttpRequestMatcher: Send + Debug { + /// Returns whether the given [`CanisterHttpRequest`] matches. + fn matches(&self, request: &CanisterHttpRequest) -> bool; +} + +/// A wrapper over [`CanisterHttpReply`] that offers a fluent API to create instances. +/// +/// # Examples +/// +/// ```rust +/// use ic_mock_http_canister_runtime::CanisterHttpReply; +/// use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpResponse}; +/// use serde_json::json; +/// +/// let response: CanisterHttpResponse = CanisterHttpReply::with_status(200) +/// .with_body(json!({ +/// "jsonrpc": "2.0", +/// "result": 19, +/// "id": 1 +/// })) +/// .with_headers(vec![("Content-Type", "application/json")]) +/// .into(); +/// +/// assert_eq!(response, CanisterHttpResponse::CanisterHttpReply( +/// pocket_ic::common::rest::CanisterHttpReply { +/// status: 200, +/// headers: vec![ +/// CanisterHttpHeader { +/// name: "Content-Type".to_string(), +/// value: "application/json".to_string() +/// } +/// ], +/// body: serde_json::to_vec(&json!({ +/// "jsonrpc": "2.0", +/// "result": 19, +/// "id": 1 +/// })).unwrap(), +/// } +/// )) +/// ``` +/// +/// [`CanisterHttpReply`]: pocket_ic::common::rest::CanisterHttpReply +pub struct CanisterHttpReply(pocket_ic::common::rest::CanisterHttpReply); + +impl CanisterHttpReply { + /// Create a [`CanisterHttpReply`] with the given status. + pub fn with_status(status: u16) -> Self { + Self(pocket_ic::common::rest::CanisterHttpReply { + status, + headers: vec![], + body: vec![], + }) + } + + /// Mutates the [`CanisterHttpReply`] to set the body. + pub fn with_body(mut self, body: impl Into) -> Self { + self.0.body = serde_json::to_vec(&body.into()).unwrap(); + self + } + + /// Mutates the [`CanisterHttpReply`] to set the headers. + pub fn with_headers( + mut self, + headers: impl IntoIterator, + ) -> Self { + self.0.headers = headers + .into_iter() + .map(|(name, value)| CanisterHttpHeader { + name: name.to_string(), + value: value.to_string(), + }) + .collect(); + self + } +} + +impl From for CanisterHttpResponse { + fn from(value: CanisterHttpReply) -> Self { + CanisterHttpResponse::CanisterHttpReply(value.0) + } +} + +/// A wrapper over [`CanisterHttpReject`] that offers a fluent API to create instances. +/// +/// # Examples +/// +/// ```rust +/// use ic_error_types::RejectCode; +/// use ic_mock_http_canister_runtime::CanisterHttpReject; +/// use pocket_ic::common::rest::CanisterHttpResponse; +/// +/// let response: CanisterHttpResponse = CanisterHttpReject::with_reject_code(RejectCode::SysTransient) +/// .with_message("No consensus could be reached. Replicas had different responses.") +/// .into(); +/// +/// assert_eq!(response, CanisterHttpResponse::CanisterHttpReject( +/// pocket_ic::common::rest::CanisterHttpReject { +/// reject_code: RejectCode::SysTransient as u64, +/// message: "No consensus could be reached. Replicas had different responses.".to_string(), +/// } +/// )) +/// ``` +/// +/// [`CanisterHttpReject`]: pocket_ic::common::rest::CanisterHttpReject +pub struct CanisterHttpReject(pocket_ic::common::rest::CanisterHttpReject); + +impl CanisterHttpReject { + /// Create a [`CanisterHttpReject`] with the given reject code. + pub fn with_reject_code(reject_code: ic_error_types::RejectCode) -> Self { + Self(pocket_ic::common::rest::CanisterHttpReject { + reject_code: reject_code as u64, + message: String::new(), + }) + } + + /// Mutates the [`CanisterHttpReject`] to set the message. + pub fn with_message(mut self, message: impl Into) -> Self { + self.0.message = message.into(); + self + } +} + +impl From for CanisterHttpResponse { + fn from(value: CanisterHttpReject) -> Self { + CanisterHttpResponse::CanisterHttpReject(value.0) + } +} From 3794425441fb0d76fd1bf4b9988da1473f8f1296 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 6 Nov 2025 15:48:09 +0100 Subject: [PATCH 2/2] Fix `Cargo.toml` doc link and description --- ic-mock-http-canister-runtime/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ic-mock-http-canister-runtime/Cargo.toml b/ic-mock-http-canister-runtime/Cargo.toml index b0dc103..f457fa0 100644 --- a/ic-mock-http-canister-runtime/Cargo.toml +++ b/ic-mock-http-canister-runtime/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ic-mock-http-canister-runtime" version = "0.1.0" -description = "Rust to mock HTTP outcalls with Pocket IC" +description = "Mock HTTP outcalls with Pocket IC" license.workspace = true readme.workspace = true homepage.workspace = true @@ -9,7 +9,7 @@ authors.workspace = true edition.workspace = true include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] repository.workspace = true -documentation = "https://docs.rs/mock_http_runtime" +documentation = "https://docs.rs/ic-mock-http-canister-runtime" [dependencies] async-trait = { workspace = true }