diff --git a/ic-pocket-canister-runtime/src/lib.rs b/ic-pocket-canister-runtime/src/lib.rs index 7034a65..b319f5c 100644 --- a/ic-pocket-canister-runtime/src/lib.rs +++ b/ic-pocket-canister-runtime/src/lib.rs @@ -13,7 +13,10 @@ use ic_canister_runtime::{IcError, Runtime}; use ic_cdk::call::{CallFailed, CallRejected}; use ic_error_types::RejectCode; pub use mock::{ - json::{JsonRpcRequestMatcher, JsonRpcResponse}, + json::{ + BatchJsonRpcRequestMatcher, BatchJsonRpcResponse, HttpRequestMatcher, HttpResponse, + JsonRpcRequestMatcher, JsonRpcResponse, SingleJsonRpcMatcher, + }, AnyCanisterHttpRequestMatcher, CanisterHttpReject, CanisterHttpReply, CanisterHttpRequestMatcher, MockHttpOutcall, MockHttpOutcallBuilder, MockHttpOutcalls, MockHttpOutcallsBuilder, diff --git a/ic-pocket-canister-runtime/src/mock/json/mod.rs b/ic-pocket-canister-runtime/src/mock/json/mod.rs index 91999cc..2f2b6de 100644 --- a/ic-pocket-canister-runtime/src/mock/json/mod.rs +++ b/ic-pocket-canister-runtime/src/mock/json/mod.rs @@ -7,43 +7,36 @@ use pocket_ic::common::rest::{ CanisterHttpHeader, CanisterHttpMethod, CanisterHttpReply, CanisterHttpRequest, CanisterHttpResponse, }; +use serde::Serialize; use serde_json::Value; use std::{collections::BTreeSet, str::FromStr}; use url::{Host, Url}; -/// Matches [`CanisterHttpRequest`]s whose body is a JSON-RPC request. +/// Matches the body of a single JSON-RPC request. #[derive(Clone, Debug)] -pub struct JsonRpcRequestMatcher { +pub struct SingleJsonRpcMatcher { 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. +impl SingleJsonRpcMatcher { + /// Create a [`SingleJsonRpcMatcher`] 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 + /// Mutates the [`SingleJsonRpcMatcher`] 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 + /// Mutates the [`SingleJsonRpcMatcher`] 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 { @@ -52,7 +45,7 @@ impl JsonRpcRequestMatcher { } } - /// Mutates the [`JsonRpcRequestMatcher`] to match only requests with the given JSON-RPC request + /// Mutates the [`SingleJsonRpcMatcher`] to match only requests with the given JSON-RPC request /// parameters. pub fn with_params(self, params: impl Into) -> Self { Self { @@ -61,7 +54,43 @@ impl JsonRpcRequestMatcher { } } - /// Mutates the [`JsonRpcRequestMatcher`] to match only requests with the given [URL]. + fn matches_body(&self, request: &JsonRpcRequest) -> bool { + if self.method != request.method() { + return false; + } + if let Some(ref id) = self.id { + if id != request.id() { + return false; + } + } + if let Some(ref params) = self.params { + if Some(params) != request.params() { + return false; + } + } + true + } +} + +/// Matches [`CanisterHttpRequest`]s whose body can be deserialized and matched by `B`. +/// +/// The type parameter `B` determines what kind of body is matched: +/// * [`SingleJsonRpcMatcher`] for single JSON-RPC requests (see [`JsonRpcRequestMatcher`]) +/// * `Vec` for batch JSON-RPC requests (see [`BatchJsonRpcRequestMatcher`]) +#[derive(Clone, Debug)] +pub struct HttpRequestMatcher { + body: B, + url: Option, + host: Option, + request_headers: Option>, + max_response_bytes: Option, +} + +/// Matches [`CanisterHttpRequest`]s whose body is a single JSON-RPC request. +pub type JsonRpcRequestMatcher = HttpRequestMatcher; + +impl HttpRequestMatcher { + /// Mutates the matcher 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 { @@ -71,7 +100,7 @@ impl JsonRpcRequestMatcher { } } - /// Mutates the [`JsonRpcRequestMatcher`] to match only requests whose [URL] has the given host. + /// Mutates the matcher 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 { @@ -81,7 +110,7 @@ impl JsonRpcRequestMatcher { } } - /// Mutates the [`JsonRpcRequestMatcher`] to match requests with the given HTTP headers. + /// Mutates the matcher 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( @@ -97,8 +126,7 @@ impl JsonRpcRequestMatcher { } } - /// Mutates the [`JsonRpcRequestMatcher`] to match requests with the given - /// [`max_response_bytes`]. + /// Mutates the matcher 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 { @@ -107,10 +135,8 @@ impl JsonRpcRequestMatcher { ..self } } -} -impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { - fn matches(&self, request: &CanisterHttpRequest) -> bool { + fn matches_http(&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 { @@ -141,25 +167,6 @@ impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { 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; @@ -169,15 +176,123 @@ impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { } } -/// A mocked JSON-RPC HTTP outcall response. +impl HttpRequestMatcher { + /// Create a [`JsonRpcRequestMatcher`] that matches only JSON-RPC requests with the given method. + pub fn with_method(method: impl Into) -> Self { + Self { + body: SingleJsonRpcMatcher::with_method(method), + 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 { + body: self.body.with_id(id), + ..self + } + } + + /// 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 { + body: self.body.with_raw_id(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 { + body: self.body.with_params(params), + ..self + } + } +} + +impl CanisterHttpRequestMatcher for HttpRequestMatcher { + fn matches(&self, request: &CanisterHttpRequest) -> bool { + if !self.matches_http(request) { + return false; + } + match serde_json::from_slice::>(&request.body) { + Ok(actual_body) => self.body.matches_body(&actual_body), + Err(_) => false, + } + } +} + +/// Matches [`CanisterHttpRequest`]s whose body is a batch JSON-RPC request. +pub type BatchJsonRpcRequestMatcher = HttpRequestMatcher>; + +impl HttpRequestMatcher> { + /// Create a [`BatchJsonRpcRequestMatcher`] that matches a batch JSON-RPC request + /// containing exactly the given individual matchers, matched pairwise in order. + pub fn batch(matchers: Vec) -> Self { + Self { + body: matchers, + url: None, + host: None, + request_headers: None, + max_response_bytes: None, + } + } +} + +impl CanisterHttpRequestMatcher for HttpRequestMatcher> { + fn matches(&self, request: &CanisterHttpRequest) -> bool { + if !self.matches_http(request) { + return false; + } + match serde_json::from_slice::>>(&request.body) { + Ok(actual_batch) => { + actual_batch.len() == self.body.len() + && self + .body + .iter() + .zip(actual_batch.iter()) + .all(|(matcher, req)| matcher.matches_body(req)) + } + Err(_) => false, + } + } +} + +/// A mocked HTTP outcall response. +/// +/// The type parameter `B` determines what kind of body is returned: +/// * [`Value`] for single JSON-RPC responses (see [`JsonRpcResponse`]) +/// * `Vec` for batch JSON-RPC responses (see [`BatchJsonRpcResponse`]) #[derive(Clone)] -pub struct JsonRpcResponse { +pub struct HttpResponse { status: u16, headers: Vec, - body: Value, + body: B, +} + +/// A mocked single JSON-RPC HTTP outcall response. +pub type JsonRpcResponse = HttpResponse; + +/// A mocked batch JSON-RPC HTTP outcall response. +pub type BatchJsonRpcResponse = HttpResponse>; + +impl From> for CanisterHttpResponse { + fn from(response: HttpResponse) -> Self { + CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply { + status: response.status, + headers: response.headers, + body: serde_json::to_vec(&response.body).unwrap(), + }) + } } -impl From for JsonRpcResponse { +impl From for HttpResponse { fn from(body: Value) -> Self { Self { status: 200, @@ -187,44 +302,44 @@ impl From for JsonRpcResponse { } } -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 { +impl From<&Value> for HttpResponse { fn from(body: &Value) -> Self { Self::from(body.clone()) } } -impl From for JsonRpcResponse { +impl From for HttpResponse { fn from(body: String) -> Self { Self::from(Value::from_str(&body).expect("BUG: invalid JSON-RPC response")) } } -impl From<&str> for JsonRpcResponse { +impl From<&str> for HttpResponse { 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(), - }) +impl HttpResponse { + /// 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) -> Self { + 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) -> Self { + self.body["id"] = serde_json::to_value(id).expect("BUG: cannot serialize ID"); + self + } +} + +impl From> for HttpResponse> { + fn from(body: Vec) -> Self { + Self { + status: 200, + headers: vec![], + body, + } } } diff --git a/ic-pocket-canister-runtime/src/mock/json/tests.rs b/ic-pocket-canister-runtime/src/mock/json/tests.rs index fc40b31..2cf8cd5 100644 --- a/ic-pocket-canister-runtime/src/mock/json/tests.rs +++ b/ic-pocket-canister-runtime/src/mock/json/tests.rs @@ -136,3 +136,190 @@ fn request() -> CanisterHttpRequest { max_response_bytes: Some(DEFAULT_MAX_RESPONSE_BYTES), } } + +mod batch_json_rpc_request_matcher_tests { + use super::{ + request, CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE, DEFAULT_HOST, + DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_RPC_ID, DEFAULT_RPC_METHOD, DEFAULT_RPC_PARAMS, + DEFAULT_URL, SUBNET_ID, + }; + use crate::mock::json::{HttpRequestMatcher, SingleJsonRpcMatcher}; + use crate::mock::CanisterHttpRequestMatcher; + use canhttp::http::json::{ConstantSizeId, Id}; + use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpMethod, CanisterHttpRequest}; + use serde_json::{json, Value}; + + const SECOND_RPC_METHOD: &str = "eth_getBlockByNumber"; + const SECOND_RPC_ID: u64 = 5678; + + #[test] + fn should_match_batch_request() { + assert!(batch_matcher().matches(&batch_request())); + } + + #[test] + fn should_not_match_single_request() { + assert!(!batch_matcher().matches(&request())); + } + + #[test] + fn should_not_match_wrong_method_in_batch() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method("eth_getLogs").with_id(DEFAULT_RPC_ID), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD).with_id(SECOND_RPC_ID), + ]); + assert!(!matcher.matches(&batch_request())); + } + + #[test] + fn should_not_match_wrong_batch_size() { + let matcher = + HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD).with_id(DEFAULT_RPC_ID) + ]); + assert!(!matcher.matches(&batch_request())); + } + + #[test] + fn should_not_match_wrong_order() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD).with_id(SECOND_RPC_ID), + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD).with_id(DEFAULT_RPC_ID), + ]); + assert!(!matcher.matches(&batch_request())); + } + + #[test] + fn should_match_batch_with_url() { + assert!(batch_matcher() + .with_url(DEFAULT_URL) + .matches(&batch_request())); + } + + #[test] + fn should_not_match_batch_with_wrong_url() { + assert!(!batch_matcher() + .with_url("https://rpc.ankr.com") + .matches(&batch_request())); + } + + #[test] + fn should_match_batch_with_host() { + assert!(batch_matcher() + .with_host(DEFAULT_HOST) + .matches(&batch_request())); + } + + #[test] + fn should_not_match_batch_with_wrong_host() { + assert!(!batch_matcher() + .with_host("rpc.ankr.com") + .matches(&batch_request())); + } + + #[test] + fn should_match_batch_with_params() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD) + .with_id(DEFAULT_RPC_ID) + .with_params(DEFAULT_RPC_PARAMS), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD) + .with_id(SECOND_RPC_ID) + .with_params(json!(["0x1", true])), + ]); + assert!(matcher.matches(&batch_request())); + } + + #[test] + fn should_not_match_batch_with_wrong_params() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD) + .with_id(DEFAULT_RPC_ID) + .with_params(Value::Null), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD).with_id(SECOND_RPC_ID), + ]); + assert!(!matcher.matches(&batch_request())); + } + + #[test] + fn should_not_match_wrong_id_in_batch() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD).with_id(9999), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD).with_id(SECOND_RPC_ID), + ]); + assert!(!matcher.matches(&batch_request())); + } + + #[test] + fn should_not_match_wrong_raw_id_in_batch() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD).with_raw_id(Id::Null), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD).with_id(SECOND_RPC_ID), + ]); + assert!(!matcher.matches(&batch_request())); + } + + #[test] + fn should_not_match_swapped_ids_in_batch() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD).with_id(SECOND_RPC_ID), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD).with_id(DEFAULT_RPC_ID), + ]); + assert!(!matcher.matches(&batch_request())); + } + + #[test] + fn should_match_batch_without_id_constraint() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD), + ]); + assert!(matcher.matches(&batch_request())); + } + + #[test] + fn should_not_match_numeric_id_instead_of_string_id() { + let matcher = HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD) + .with_raw_id(Id::Number(DEFAULT_RPC_ID)), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD).with_id(SECOND_RPC_ID), + ]); + assert!(!matcher.matches(&batch_request())); + } + + fn batch_matcher() -> HttpRequestMatcher> { + HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD).with_id(DEFAULT_RPC_ID), + SingleJsonRpcMatcher::with_method(SECOND_RPC_METHOD).with_id(SECOND_RPC_ID), + ]) + } + + fn batch_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, + }, + { + "jsonrpc": "2.0", + "method": SECOND_RPC_METHOD, + "id": ConstantSizeId::from(SECOND_RPC_ID).to_string(), + "params": ["0x1", true], + }, + ])) + .unwrap(), + max_response_bytes: Some(DEFAULT_MAX_RESPONSE_BYTES), + } + } +}