From 0d8ad5e2321f44ea396a305aabe20d85e27b7619 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 11 Mar 2026 11:40:01 +0100 Subject: [PATCH 1/9] refactor: extract `SingleJsonRpcMatcher` from `JsonRpcRequestMatcher` Extract JSON-RPC body matching fields (method, id, params) into a dedicated `SingleJsonRpcMatcher` struct with its own builder methods and a `matches_body` helper. This prepares for making the request matcher generic over single vs batch JSON-RPC bodies. Co-Authored-By: Claude Opus 4.6 --- .../src/mock/json/mod.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/ic-pocket-canister-runtime/src/mock/json/mod.rs b/ic-pocket-canister-runtime/src/mock/json/mod.rs index 91999cc..fba0877 100644 --- a/ic-pocket-canister-runtime/src/mock/json/mod.rs +++ b/ic-pocket-canister-runtime/src/mock/json/mod.rs @@ -11,6 +11,66 @@ use serde_json::Value; use std::{collections::BTreeSet, str::FromStr}; use url::{Host, Url}; +/// Matches the body of a single JSON-RPC request. +#[derive(Clone, Debug)] +pub struct SingleJsonRpcMatcher { + method: String, + id: Option, + params: Option, +} + +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, + } + } + + /// 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 [`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 { + id: Some(id), + ..self + } + } + + /// Mutates the [`SingleJsonRpcMatcher`] 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 + } + } + + 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 is a JSON-RPC request. #[derive(Clone, Debug)] pub struct JsonRpcRequestMatcher { From fa0755267f5ebcac3a4a5beb988d50df03c631c4 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 11 Mar 2026 11:42:51 +0100 Subject: [PATCH 2/9] refactor: make `JsonRpcRequestMatcher` generic over the body matcher Rename the struct to `JsonRpcHttpRequestMatcher` and introduce: - `impl` block for shared HTTP-level builders and `matches_http` helper - `impl JsonRpcHttpRequestMatcher` for body-specific builders (`with_method`, `with_id`, `with_params`) and the `CanisterHttpRequestMatcher` trait impl - Type alias `JsonRpcRequestMatcher = JsonRpcHttpRequestMatcher` preserving full backward compatibility Co-Authored-By: Claude Opus 4.6 --- .../src/mock/json/mod.rs | 134 +++++++++--------- 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/ic-pocket-canister-runtime/src/mock/json/mod.rs b/ic-pocket-canister-runtime/src/mock/json/mod.rs index fba0877..de3e4df 100644 --- a/ic-pocket-canister-runtime/src/mock/json/mod.rs +++ b/ic-pocket-canister-runtime/src/mock/json/mod.rs @@ -72,56 +72,24 @@ impl SingleJsonRpcMatcher { } /// Matches [`CanisterHttpRequest`]s whose body is a JSON-RPC request. +/// +/// The type parameter `B` determines what kind of JSON-RPC body is matched: +/// * [`SingleJsonRpcMatcher`] for single JSON-RPC requests (see [`JsonRpcRequestMatcher`]) +/// * `Vec` for batch JSON-RPC requests (see [`BatchJsonRpcRequestMatcher`]) #[derive(Clone, Debug)] -pub struct JsonRpcRequestMatcher { - method: String, - id: Option, - params: Option, +pub struct JsonRpcHttpRequestMatcher { + body: B, 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, - } - } +/// Matches [`CanisterHttpRequest`]s whose body is a single JSON-RPC request. +pub type JsonRpcRequestMatcher = JsonRpcHttpRequestMatcher; - /// 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]. +impl JsonRpcHttpRequestMatcher { + /// 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 { @@ -131,7 +99,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 { @@ -141,7 +109,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( @@ -157,8 +125,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 { @@ -167,10 +134,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 { @@ -201,25 +166,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; @@ -229,6 +175,58 @@ impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { } } +impl JsonRpcHttpRequestMatcher { + /// 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 JsonRpcHttpRequestMatcher { + 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, + } + } +} + /// A mocked JSON-RPC HTTP outcall response. #[derive(Clone)] pub struct JsonRpcResponse { From dd0bc0663f60e4e2241dd806b2586b4cb4a0fe13 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 11 Mar 2026 11:44:16 +0100 Subject: [PATCH 3/9] feat: add `BatchJsonRpcRequestMatcher` for batch JSON-RPC requests Add `JsonRpcHttpRequestMatcher>` with a `batch` constructor and a `CanisterHttpRequestMatcher` impl that deserializes the body as a JSON array and matches pairwise in order. Introduce `BatchJsonRpcRequestMatcher` type alias for ergonomics. Co-Authored-By: Claude Opus 4.6 --- .../src/mock/json/mod.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/ic-pocket-canister-runtime/src/mock/json/mod.rs b/ic-pocket-canister-runtime/src/mock/json/mod.rs index de3e4df..6e0dfa8 100644 --- a/ic-pocket-canister-runtime/src/mock/json/mod.rs +++ b/ic-pocket-canister-runtime/src/mock/json/mod.rs @@ -227,6 +227,42 @@ impl CanisterHttpRequestMatcher for JsonRpcHttpRequestMatcher>; + +impl JsonRpcHttpRequestMatcher> { + /// 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 JsonRpcHttpRequestMatcher> { + 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 JSON-RPC HTTP outcall response. #[derive(Clone)] pub struct JsonRpcResponse { From bde8126b07a54a352cab602036e9bee451b6aa02 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 11 Mar 2026 11:52:53 +0100 Subject: [PATCH 4/9] feat: add batch JSON-RPC request matcher tests and re-exports Add `batch_json_rpc_request_matcher_tests` module with tests covering batch matching, wrong method/size/order/params, and HTTP-level constraints (url, host). Export new types (`BatchJsonRpcRequestMatcher`, `JsonRpcHttpRequestMatcher`, `SingleJsonRpcMatcher`) from `lib.rs`. Co-Authored-By: Claude Opus 4.6 --- ic-pocket-canister-runtime/src/lib.rs | 5 +- .../src/mock/json/tests.rs | 140 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/ic-pocket-canister-runtime/src/lib.rs b/ic-pocket-canister-runtime/src/lib.rs index 7034a65..87b86dc 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, JsonRpcHttpRequestMatcher, JsonRpcRequestMatcher, + JsonRpcResponse, SingleJsonRpcMatcher, + }, AnyCanisterHttpRequestMatcher, CanisterHttpReject, CanisterHttpReply, CanisterHttpRequestMatcher, MockHttpOutcall, MockHttpOutcallBuilder, MockHttpOutcalls, MockHttpOutcallsBuilder, diff --git a/ic-pocket-canister-runtime/src/mock/json/tests.rs b/ic-pocket-canister-runtime/src/mock/json/tests.rs index fc40b31..6909c49 100644 --- a/ic-pocket-canister-runtime/src/mock/json/tests.rs +++ b/ic-pocket-canister-runtime/src/mock/json/tests.rs @@ -136,3 +136,143 @@ 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::{JsonRpcHttpRequestMatcher, SingleJsonRpcMatcher}; + use crate::mock::CanisterHttpRequestMatcher; + use canhttp::http::json::ConstantSizeId; + 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 = JsonRpcHttpRequestMatcher::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 = JsonRpcHttpRequestMatcher::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 = JsonRpcHttpRequestMatcher::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 = JsonRpcHttpRequestMatcher::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 = JsonRpcHttpRequestMatcher::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())); + } + + fn batch_matcher() -> JsonRpcHttpRequestMatcher> { + JsonRpcHttpRequestMatcher::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), + } + } +} From e0f9b26daf1adaa782676960f7d29cdd8e8b70d7 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 11 Mar 2026 13:08:16 +0100 Subject: [PATCH 5/9] refactor: make `JsonRpcResponse` generic over the body type Rename the struct to `JsonRpcHttpResponse` and introduce: - `JsonRpcResponse` type alias for `JsonRpcHttpResponse` (single) - `BatchJsonRpcResponse` type alias for `JsonRpcHttpResponse>` - Generic `From> for CanisterHttpResponse` impl - Single-specific `with_id`/`with_raw_id` and `From` conversions - Batch-specific `From>` conversion Co-Authored-By: Claude Opus 4.6 --- ic-pocket-canister-runtime/src/lib.rs | 4 +- .../src/mock/json/mod.rs | 75 ++++++++++++------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/ic-pocket-canister-runtime/src/lib.rs b/ic-pocket-canister-runtime/src/lib.rs index 87b86dc..c0a2d7b 100644 --- a/ic-pocket-canister-runtime/src/lib.rs +++ b/ic-pocket-canister-runtime/src/lib.rs @@ -14,8 +14,8 @@ use ic_cdk::call::{CallFailed, CallRejected}; use ic_error_types::RejectCode; pub use mock::{ json::{ - BatchJsonRpcRequestMatcher, JsonRpcHttpRequestMatcher, JsonRpcRequestMatcher, - JsonRpcResponse, SingleJsonRpcMatcher, + BatchJsonRpcRequestMatcher, BatchJsonRpcResponse, JsonRpcHttpRequestMatcher, + JsonRpcHttpResponse, JsonRpcRequestMatcher, JsonRpcResponse, SingleJsonRpcMatcher, }, AnyCanisterHttpRequestMatcher, CanisterHttpReject, CanisterHttpReply, CanisterHttpRequestMatcher, MockHttpOutcall, MockHttpOutcallBuilder, MockHttpOutcalls, diff --git a/ic-pocket-canister-runtime/src/mock/json/mod.rs b/ic-pocket-canister-runtime/src/mock/json/mod.rs index 6e0dfa8..04672e9 100644 --- a/ic-pocket-canister-runtime/src/mock/json/mod.rs +++ b/ic-pocket-canister-runtime/src/mock/json/mod.rs @@ -7,6 +7,7 @@ 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}; @@ -264,14 +265,34 @@ impl CanisterHttpRequestMatcher for JsonRpcHttpRequestMatcher` for batch JSON-RPC responses (see [`BatchJsonRpcResponse`]) #[derive(Clone)] -pub struct JsonRpcResponse { +pub struct JsonRpcHttpResponse { status: u16, headers: Vec, - body: Value, + body: B, +} + +/// A mocked single JSON-RPC HTTP outcall response. +pub type JsonRpcResponse = JsonRpcHttpResponse; + +/// A mocked batch JSON-RPC HTTP outcall response. +pub type BatchJsonRpcResponse = JsonRpcHttpResponse>; + +impl From> for CanisterHttpResponse { + fn from(response: JsonRpcHttpResponse) -> 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 JsonRpcHttpResponse { fn from(body: Value) -> Self { Self { status: 200, @@ -281,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 JsonRpcHttpResponse { fn from(body: &Value) -> Self { Self::from(body.clone()) } } -impl From for JsonRpcResponse { +impl From for JsonRpcHttpResponse { 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 JsonRpcHttpResponse { 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 JsonRpcHttpResponse { + /// 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 JsonRpcHttpResponse> { + fn from(body: Vec) -> Self { + Self { + status: 200, + headers: vec![], + body, + } } } From 49d49eb3f400f18a94786f5c546bee52427f63d9 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Wed, 11 Mar 2026 13:13:30 +0100 Subject: [PATCH 6/9] style: apply cargo fmt Co-Authored-By: Claude Opus 4.6 --- ic-pocket-canister-runtime/src/mock/json/tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ic-pocket-canister-runtime/src/mock/json/tests.rs b/ic-pocket-canister-runtime/src/mock/json/tests.rs index 6909c49..564bdc6 100644 --- a/ic-pocket-canister-runtime/src/mock/json/tests.rs +++ b/ic-pocket-canister-runtime/src/mock/json/tests.rs @@ -173,9 +173,10 @@ mod batch_json_rpc_request_matcher_tests { #[test] fn should_not_match_wrong_batch_size() { - let matcher = JsonRpcHttpRequestMatcher::batch(vec![ - SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD).with_id(DEFAULT_RPC_ID), - ]); + let matcher = JsonRpcHttpRequestMatcher::batch(vec![SingleJsonRpcMatcher::with_method( + DEFAULT_RPC_METHOD, + ) + .with_id(DEFAULT_RPC_ID)]); assert!(!matcher.matches(&batch_request())); } From 3a49a028ec8c3a07223be2f4d5002dd854fd976c Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Fri, 20 Mar 2026 11:35:33 +0100 Subject: [PATCH 7/9] DEFI-2565: rename generic structs --- ic-pocket-canister-runtime/src/lib.rs | 4 +- .../src/mock/json/mod.rs | 46 +++++++++---------- .../src/mock/json/tests.rs | 16 +++---- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/ic-pocket-canister-runtime/src/lib.rs b/ic-pocket-canister-runtime/src/lib.rs index c0a2d7b..6eda5cb 100644 --- a/ic-pocket-canister-runtime/src/lib.rs +++ b/ic-pocket-canister-runtime/src/lib.rs @@ -14,8 +14,8 @@ use ic_cdk::call::{CallFailed, CallRejected}; use ic_error_types::RejectCode; pub use mock::{ json::{ - BatchJsonRpcRequestMatcher, BatchJsonRpcResponse, JsonRpcHttpRequestMatcher, - JsonRpcHttpResponse, JsonRpcRequestMatcher, JsonRpcResponse, SingleJsonRpcMatcher, + BatchJsonRpcRequestMatcher, BatchJsonRpcResponse, HttpRequestMatcher, + HttpResponse, JsonRpcRequestMatcher, JsonRpcResponse, SingleJsonRpcMatcher, }, AnyCanisterHttpRequestMatcher, CanisterHttpReject, CanisterHttpReply, CanisterHttpRequestMatcher, MockHttpOutcall, MockHttpOutcallBuilder, MockHttpOutcalls, diff --git a/ic-pocket-canister-runtime/src/mock/json/mod.rs b/ic-pocket-canister-runtime/src/mock/json/mod.rs index 04672e9..2f2b6de 100644 --- a/ic-pocket-canister-runtime/src/mock/json/mod.rs +++ b/ic-pocket-canister-runtime/src/mock/json/mod.rs @@ -72,13 +72,13 @@ impl SingleJsonRpcMatcher { } } -/// Matches [`CanisterHttpRequest`]s whose body is a JSON-RPC request. +/// Matches [`CanisterHttpRequest`]s whose body can be deserialized and matched by `B`. /// -/// The type parameter `B` determines what kind of JSON-RPC body is matched: +/// 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 JsonRpcHttpRequestMatcher { +pub struct HttpRequestMatcher { body: B, url: Option, host: Option, @@ -87,9 +87,9 @@ pub struct JsonRpcHttpRequestMatcher { } /// Matches [`CanisterHttpRequest`]s whose body is a single JSON-RPC request. -pub type JsonRpcRequestMatcher = JsonRpcHttpRequestMatcher; +pub type JsonRpcRequestMatcher = HttpRequestMatcher; -impl JsonRpcHttpRequestMatcher { +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 @@ -176,7 +176,7 @@ impl JsonRpcHttpRequestMatcher { } } -impl JsonRpcHttpRequestMatcher { +impl HttpRequestMatcher { /// Create a [`JsonRpcRequestMatcher`] that matches only JSON-RPC requests with the given method. pub fn with_method(method: impl Into) -> Self { Self { @@ -216,7 +216,7 @@ impl JsonRpcHttpRequestMatcher { } } -impl CanisterHttpRequestMatcher for JsonRpcHttpRequestMatcher { +impl CanisterHttpRequestMatcher for HttpRequestMatcher { fn matches(&self, request: &CanisterHttpRequest) -> bool { if !self.matches_http(request) { return false; @@ -229,9 +229,9 @@ impl CanisterHttpRequestMatcher for JsonRpcHttpRequestMatcher>; +pub type BatchJsonRpcRequestMatcher = HttpRequestMatcher>; -impl JsonRpcHttpRequestMatcher> { +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 { @@ -245,7 +245,7 @@ impl JsonRpcHttpRequestMatcher> { } } -impl CanisterHttpRequestMatcher for JsonRpcHttpRequestMatcher> { +impl CanisterHttpRequestMatcher for HttpRequestMatcher> { fn matches(&self, request: &CanisterHttpRequest) -> bool { if !self.matches_http(request) { return false; @@ -264,26 +264,26 @@ impl CanisterHttpRequestMatcher for JsonRpcHttpRequestMatcher` for batch JSON-RPC responses (see [`BatchJsonRpcResponse`]) #[derive(Clone)] -pub struct JsonRpcHttpResponse { +pub struct HttpResponse { status: u16, headers: Vec, body: B, } /// A mocked single JSON-RPC HTTP outcall response. -pub type JsonRpcResponse = JsonRpcHttpResponse; +pub type JsonRpcResponse = HttpResponse; /// A mocked batch JSON-RPC HTTP outcall response. -pub type BatchJsonRpcResponse = JsonRpcHttpResponse>; +pub type BatchJsonRpcResponse = HttpResponse>; -impl From> for CanisterHttpResponse { - fn from(response: JsonRpcHttpResponse) -> Self { +impl From> for CanisterHttpResponse { + fn from(response: HttpResponse) -> Self { CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply { status: response.status, headers: response.headers, @@ -292,7 +292,7 @@ impl From> for CanisterHttpResponse { } } -impl From for JsonRpcHttpResponse { +impl From for HttpResponse { fn from(body: Value) -> Self { Self { status: 200, @@ -302,25 +302,25 @@ impl From for JsonRpcHttpResponse { } } -impl From<&Value> for JsonRpcHttpResponse { +impl From<&Value> for HttpResponse { fn from(body: &Value) -> Self { Self::from(body.clone()) } } -impl From for JsonRpcHttpResponse { +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 JsonRpcHttpResponse { +impl From<&str> for HttpResponse { fn from(body: &str) -> Self { Self::from(body.to_string()) } } -impl JsonRpcHttpResponse { +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 { @@ -334,7 +334,7 @@ impl JsonRpcHttpResponse { } } -impl From> for JsonRpcHttpResponse> { +impl From> for HttpResponse> { fn from(body: Vec) -> Self { Self { status: 200, diff --git a/ic-pocket-canister-runtime/src/mock/json/tests.rs b/ic-pocket-canister-runtime/src/mock/json/tests.rs index 564bdc6..ee75878 100644 --- a/ic-pocket-canister-runtime/src/mock/json/tests.rs +++ b/ic-pocket-canister-runtime/src/mock/json/tests.rs @@ -143,7 +143,7 @@ mod batch_json_rpc_request_matcher_tests { DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_RPC_ID, DEFAULT_RPC_METHOD, DEFAULT_RPC_PARAMS, DEFAULT_URL, SUBNET_ID, }; - use crate::mock::json::{JsonRpcHttpRequestMatcher, SingleJsonRpcMatcher}; + use crate::mock::json::{HttpRequestMatcher, SingleJsonRpcMatcher}; use crate::mock::CanisterHttpRequestMatcher; use canhttp::http::json::ConstantSizeId; use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpMethod, CanisterHttpRequest}; @@ -164,7 +164,7 @@ mod batch_json_rpc_request_matcher_tests { #[test] fn should_not_match_wrong_method_in_batch() { - let matcher = JsonRpcHttpRequestMatcher::batch(vec![ + 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), ]); @@ -173,7 +173,7 @@ mod batch_json_rpc_request_matcher_tests { #[test] fn should_not_match_wrong_batch_size() { - let matcher = JsonRpcHttpRequestMatcher::batch(vec![SingleJsonRpcMatcher::with_method( + let matcher = HttpRequestMatcher::batch(vec![SingleJsonRpcMatcher::with_method( DEFAULT_RPC_METHOD, ) .with_id(DEFAULT_RPC_ID)]); @@ -182,7 +182,7 @@ mod batch_json_rpc_request_matcher_tests { #[test] fn should_not_match_wrong_order() { - let matcher = JsonRpcHttpRequestMatcher::batch(vec![ + 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), ]); @@ -219,7 +219,7 @@ mod batch_json_rpc_request_matcher_tests { #[test] fn should_match_batch_with_params() { - let matcher = JsonRpcHttpRequestMatcher::batch(vec![ + let matcher = HttpRequestMatcher::batch(vec![ SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD) .with_id(DEFAULT_RPC_ID) .with_params(DEFAULT_RPC_PARAMS), @@ -232,7 +232,7 @@ mod batch_json_rpc_request_matcher_tests { #[test] fn should_not_match_batch_with_wrong_params() { - let matcher = JsonRpcHttpRequestMatcher::batch(vec![ + let matcher = HttpRequestMatcher::batch(vec![ SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD) .with_id(DEFAULT_RPC_ID) .with_params(Value::Null), @@ -241,8 +241,8 @@ mod batch_json_rpc_request_matcher_tests { assert!(!matcher.matches(&batch_request())); } - fn batch_matcher() -> JsonRpcHttpRequestMatcher> { - JsonRpcHttpRequestMatcher::batch(vec![ + 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), ]) From 888125be134062e482a7796417b6731606090109 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Fri, 20 Mar 2026 11:43:04 +0100 Subject: [PATCH 8/9] DEFI-2565: added test for id matching --- .../src/mock/json/tests.rs | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/ic-pocket-canister-runtime/src/mock/json/tests.rs b/ic-pocket-canister-runtime/src/mock/json/tests.rs index ee75878..48309d1 100644 --- a/ic-pocket-canister-runtime/src/mock/json/tests.rs +++ b/ic-pocket-canister-runtime/src/mock/json/tests.rs @@ -145,7 +145,7 @@ mod batch_json_rpc_request_matcher_tests { }; use crate::mock::json::{HttpRequestMatcher, SingleJsonRpcMatcher}; use crate::mock::CanisterHttpRequestMatcher; - use canhttp::http::json::ConstantSizeId; + use canhttp::http::json::{ConstantSizeId, Id}; use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpMethod, CanisterHttpRequest}; use serde_json::{json, Value}; @@ -241,6 +241,52 @@ mod batch_json_rpc_request_matcher_tests { 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), From 115e418f276dfdd86fc03aafb0e60176db9a3b23 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Fri, 20 Mar 2026 11:45:28 +0100 Subject: [PATCH 9/9] DEFI-2565: formatting --- ic-pocket-canister-runtime/src/lib.rs | 4 ++-- ic-pocket-canister-runtime/src/mock/json/tests.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ic-pocket-canister-runtime/src/lib.rs b/ic-pocket-canister-runtime/src/lib.rs index 6eda5cb..b319f5c 100644 --- a/ic-pocket-canister-runtime/src/lib.rs +++ b/ic-pocket-canister-runtime/src/lib.rs @@ -14,8 +14,8 @@ use ic_cdk::call::{CallFailed, CallRejected}; use ic_error_types::RejectCode; pub use mock::{ json::{ - BatchJsonRpcRequestMatcher, BatchJsonRpcResponse, HttpRequestMatcher, - HttpResponse, JsonRpcRequestMatcher, JsonRpcResponse, SingleJsonRpcMatcher, + BatchJsonRpcRequestMatcher, BatchJsonRpcResponse, HttpRequestMatcher, HttpResponse, + JsonRpcRequestMatcher, JsonRpcResponse, SingleJsonRpcMatcher, }, AnyCanisterHttpRequestMatcher, CanisterHttpReject, CanisterHttpReply, CanisterHttpRequestMatcher, MockHttpOutcall, MockHttpOutcallBuilder, MockHttpOutcalls, diff --git a/ic-pocket-canister-runtime/src/mock/json/tests.rs b/ic-pocket-canister-runtime/src/mock/json/tests.rs index 48309d1..2cf8cd5 100644 --- a/ic-pocket-canister-runtime/src/mock/json/tests.rs +++ b/ic-pocket-canister-runtime/src/mock/json/tests.rs @@ -173,10 +173,10 @@ mod batch_json_rpc_request_matcher_tests { #[test] fn should_not_match_wrong_batch_size() { - let matcher = HttpRequestMatcher::batch(vec![SingleJsonRpcMatcher::with_method( - DEFAULT_RPC_METHOD, - ) - .with_id(DEFAULT_RPC_ID)]); + let matcher = + HttpRequestMatcher::batch(vec![ + SingleJsonRpcMatcher::with_method(DEFAULT_RPC_METHOD).with_id(DEFAULT_RPC_ID) + ]); assert!(!matcher.matches(&batch_request())); }