Skip to main content

ic_pocket_canister_runtime/mock/json/
mod.rs

1#[cfg(test)]
2mod tests;
3
4use crate::mock::CanisterHttpRequestMatcher;
5use canhttp::http::json::{ConstantSizeId, Id, JsonRpcRequest};
6use pocket_ic::common::rest::{
7    CanisterHttpHeader, CanisterHttpMethod, CanisterHttpReply, CanisterHttpRequest,
8    CanisterHttpResponse,
9};
10use serde::Serialize;
11use serde_json::Value;
12use std::{collections::BTreeSet, str::FromStr};
13use url::{Host, Url};
14
15/// Matches the body of a single JSON-RPC request.
16#[derive(Clone, Debug)]
17pub struct SingleJsonRpcMatcher {
18    method: String,
19    id: Option<Id>,
20    params: Option<Value>,
21}
22
23impl SingleJsonRpcMatcher {
24    /// Create a [`SingleJsonRpcMatcher`] that matches only JSON-RPC requests with the given method.
25    pub fn with_method(method: impl Into<String>) -> Self {
26        Self {
27            method: method.into(),
28            id: None,
29            params: None,
30        }
31    }
32
33    /// Mutates the [`SingleJsonRpcMatcher`] to match only requests whose JSON-RPC request ID is a
34    /// [`ConstantSizeId`] with the given value.
35    pub fn with_id(self, id: u64) -> Self {
36        self.with_raw_id(Id::from(ConstantSizeId::from(id)))
37    }
38
39    /// Mutates the [`SingleJsonRpcMatcher`] to match only requests whose JSON-RPC request ID is an
40    /// [`Id`] with the given value.
41    pub fn with_raw_id(self, id: Id) -> Self {
42        Self {
43            id: Some(id),
44            ..self
45        }
46    }
47
48    /// Mutates the [`SingleJsonRpcMatcher`] to match only requests with the given JSON-RPC request
49    /// parameters.
50    pub fn with_params(self, params: impl Into<Value>) -> Self {
51        Self {
52            params: Some(params.into()),
53            ..self
54        }
55    }
56
57    fn matches_body(&self, request: &JsonRpcRequest<Value>) -> bool {
58        if self.method != request.method() {
59            return false;
60        }
61        if let Some(ref id) = self.id {
62            if id != request.id() {
63                return false;
64            }
65        }
66        if let Some(ref params) = self.params {
67            if Some(params) != request.params() {
68                return false;
69            }
70        }
71        true
72    }
73}
74
75/// Matches [`CanisterHttpRequest`]s whose body can be deserialized and matched by `B`.
76///
77/// The type parameter `B` determines what kind of body is matched:
78/// * [`SingleJsonRpcMatcher`] for single JSON-RPC requests (see [`JsonRpcRequestMatcher`])
79/// * `Vec<SingleJsonRpcMatcher>` for batch JSON-RPC requests (see [`BatchJsonRpcRequestMatcher`])
80#[derive(Clone, Debug)]
81pub struct HttpRequestMatcher<B> {
82    body: B,
83    url: Option<Url>,
84    host: Option<Host>,
85    request_headers: Option<Vec<CanisterHttpHeader>>,
86    max_response_bytes: Option<u64>,
87}
88
89/// Matches [`CanisterHttpRequest`]s whose body is a single JSON-RPC request.
90pub type JsonRpcRequestMatcher = HttpRequestMatcher<SingleJsonRpcMatcher>;
91
92impl<B> HttpRequestMatcher<B> {
93    /// Mutates the matcher to match only requests with the given [URL].
94    ///
95    /// [URL]: https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request
96    pub fn with_url(self, url: &str) -> Self {
97        Self {
98            url: Some(Url::parse(url).expect("BUG: invalid URL")),
99            ..self
100        }
101    }
102
103    /// Mutates the matcher to match only requests whose [URL] has the given host.
104    ///
105    /// [URL]: https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request
106    pub fn with_host(self, host: &str) -> Self {
107        Self {
108            host: Some(Host::parse(host).expect("BUG: invalid host for a URL")),
109            ..self
110        }
111    }
112
113    /// Mutates the matcher to match requests with the given HTTP headers.
114    pub fn with_request_headers(self, headers: Vec<(impl ToString, impl ToString)>) -> Self {
115        Self {
116            request_headers: Some(
117                headers
118                    .into_iter()
119                    .map(|(name, value)| CanisterHttpHeader {
120                        name: name.to_string(),
121                        value: value.to_string(),
122                    })
123                    .collect(),
124            ),
125            ..self
126        }
127    }
128
129    /// Mutates the matcher to match requests with the given [`max_response_bytes`].
130    ///
131    /// [`max_response_bytes`]: https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request
132    pub fn with_max_response_bytes(self, max_response_bytes: impl Into<u64>) -> Self {
133        Self {
134            max_response_bytes: Some(max_response_bytes.into()),
135            ..self
136        }
137    }
138
139    fn matches_http(&self, request: &CanisterHttpRequest) -> bool {
140        let req_url = Url::from_str(&request.url).expect("BUG: invalid URL");
141        if let Some(ref mock_url) = self.url {
142            if mock_url != &req_url {
143                return false;
144            }
145        }
146        if let Some(ref host) = self.host {
147            match req_url.host() {
148                Some(ref req_host) if req_host == host => {}
149                _ => return false,
150            }
151        }
152        if CanisterHttpMethod::POST != request.http_method {
153            return false;
154        }
155        if let Some(ref headers) = self.request_headers {
156            fn lower_case_header_name(
157                CanisterHttpHeader { name, value }: &CanisterHttpHeader,
158            ) -> CanisterHttpHeader {
159                CanisterHttpHeader {
160                    name: name.to_lowercase(),
161                    value: value.clone(),
162                }
163            }
164            let expected: BTreeSet<_> = headers.iter().map(lower_case_header_name).collect();
165            let actual: BTreeSet<_> = request.headers.iter().map(lower_case_header_name).collect();
166            if expected != actual {
167                return false;
168            }
169        }
170        if let Some(max_response_bytes) = self.max_response_bytes {
171            if Some(max_response_bytes) != request.max_response_bytes {
172                return false;
173            }
174        }
175        true
176    }
177}
178
179impl HttpRequestMatcher<SingleJsonRpcMatcher> {
180    /// Create a [`JsonRpcRequestMatcher`] that matches only JSON-RPC requests with the given method.
181    pub fn with_method(method: impl Into<String>) -> Self {
182        Self {
183            body: SingleJsonRpcMatcher::with_method(method),
184            url: None,
185            host: None,
186            request_headers: None,
187            max_response_bytes: None,
188        }
189    }
190
191    /// Mutates the [`JsonRpcRequestMatcher`] to match only requests whose JSON-RPC request ID is a
192    /// [`ConstantSizeId`] with the given value.
193    pub fn with_id(self, id: u64) -> Self {
194        Self {
195            body: self.body.with_id(id),
196            ..self
197        }
198    }
199
200    /// Mutates the [`JsonRpcRequestMatcher`] to match only requests whose JSON-RPC request ID is an
201    /// [`Id`] with the given value.
202    pub fn with_raw_id(self, id: Id) -> Self {
203        Self {
204            body: self.body.with_raw_id(id),
205            ..self
206        }
207    }
208
209    /// Mutates the [`JsonRpcRequestMatcher`] to match only requests with the given JSON-RPC request
210    /// parameters.
211    pub fn with_params(self, params: impl Into<Value>) -> Self {
212        Self {
213            body: self.body.with_params(params),
214            ..self
215        }
216    }
217}
218
219impl CanisterHttpRequestMatcher for HttpRequestMatcher<SingleJsonRpcMatcher> {
220    fn matches(&self, request: &CanisterHttpRequest) -> bool {
221        if !self.matches_http(request) {
222            return false;
223        }
224        match serde_json::from_slice::<JsonRpcRequest<Value>>(&request.body) {
225            Ok(actual_body) => self.body.matches_body(&actual_body),
226            Err(_) => false,
227        }
228    }
229}
230
231/// Matches [`CanisterHttpRequest`]s whose body is a batch JSON-RPC request.
232pub type BatchJsonRpcRequestMatcher = HttpRequestMatcher<Vec<SingleJsonRpcMatcher>>;
233
234impl HttpRequestMatcher<Vec<SingleJsonRpcMatcher>> {
235    /// Create a [`BatchJsonRpcRequestMatcher`] that matches a batch JSON-RPC request
236    /// containing exactly the given individual matchers, matched pairwise in order.
237    pub fn batch(matchers: Vec<SingleJsonRpcMatcher>) -> Self {
238        Self {
239            body: matchers,
240            url: None,
241            host: None,
242            request_headers: None,
243            max_response_bytes: None,
244        }
245    }
246}
247
248impl CanisterHttpRequestMatcher for HttpRequestMatcher<Vec<SingleJsonRpcMatcher>> {
249    fn matches(&self, request: &CanisterHttpRequest) -> bool {
250        if !self.matches_http(request) {
251            return false;
252        }
253        match serde_json::from_slice::<Vec<JsonRpcRequest<Value>>>(&request.body) {
254            Ok(actual_batch) => {
255                actual_batch.len() == self.body.len()
256                    && self
257                        .body
258                        .iter()
259                        .zip(actual_batch.iter())
260                        .all(|(matcher, req)| matcher.matches_body(req))
261            }
262            Err(_) => false,
263        }
264    }
265}
266
267/// A mocked HTTP outcall response.
268///
269/// The type parameter `B` determines what kind of body is returned:
270/// * [`Value`] for single JSON-RPC responses (see [`JsonRpcResponse`])
271/// * `Vec<Value>` for batch JSON-RPC responses (see [`BatchJsonRpcResponse`])
272#[derive(Clone)]
273pub struct HttpResponse<B> {
274    status: u16,
275    headers: Vec<CanisterHttpHeader>,
276    body: B,
277}
278
279/// A mocked single JSON-RPC HTTP outcall response.
280pub type JsonRpcResponse = HttpResponse<Value>;
281
282/// A mocked batch JSON-RPC HTTP outcall response.
283pub type BatchJsonRpcResponse = HttpResponse<Vec<Value>>;
284
285impl<B: Serialize> From<HttpResponse<B>> for CanisterHttpResponse {
286    fn from(response: HttpResponse<B>) -> Self {
287        CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply {
288            status: response.status,
289            headers: response.headers,
290            body: serde_json::to_vec(&response.body).unwrap(),
291        })
292    }
293}
294
295impl From<Value> for HttpResponse<Value> {
296    fn from(body: Value) -> Self {
297        Self {
298            status: 200,
299            headers: vec![],
300            body,
301        }
302    }
303}
304
305impl From<&Value> for HttpResponse<Value> {
306    fn from(body: &Value) -> Self {
307        Self::from(body.clone())
308    }
309}
310
311impl From<String> for HttpResponse<Value> {
312    fn from(body: String) -> Self {
313        Self::from(Value::from_str(&body).expect("BUG: invalid JSON-RPC response"))
314    }
315}
316
317impl From<&str> for HttpResponse<Value> {
318    fn from(body: &str) -> Self {
319        Self::from(body.to_string())
320    }
321}
322
323impl HttpResponse<Value> {
324    /// Mutates the response to set the given JSON-RPC response ID to a [`ConstantSizeId`] with the
325    /// given value.
326    pub fn with_id(self, id: u64) -> Self {
327        self.with_raw_id(Id::from(ConstantSizeId::from(id)))
328    }
329
330    /// Mutates the response to set the given JSON-RPC response ID to the given [`Id`].
331    pub fn with_raw_id(mut self, id: Id) -> Self {
332        self.body["id"] = serde_json::to_value(id).expect("BUG: cannot serialize ID");
333        self
334    }
335}
336
337impl From<Vec<Value>> for HttpResponse<Vec<Value>> {
338    fn from(body: Vec<Value>) -> Self {
339        Self {
340            status: 200,
341            headers: vec![],
342            body,
343        }
344    }
345}