diff --git a/Cargo.lock b/Cargo.lock index 6381a60..440fb23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,9 +221,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" @@ -252,9 +252,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -301,6 +301,7 @@ dependencies = [ "assert_matches", "candid", "ciborium", + "derive_more", "futures-channel", "futures-util", "http", @@ -407,6 +408,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -587,6 +597,28 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.111", +] + [[package]] name = "digest" version = "0.9.0" @@ -1466,9 +1498,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1480,9 +1512,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -2225,9 +2257,9 @@ dependencies = [ [[package]] name = "rangemap" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbbbbea733ec66275512d0b9694f34102e7d5406fdbe2ad8d21b28dce92887c" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "redox_syscall" @@ -2263,9 +2295,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.25" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ "base64 0.22.1", "bytes", @@ -2386,9 +2418,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", @@ -3092,9 +3124,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -3126,9 +3158,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -3213,6 +3245,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 8084553..5ee6546 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ async-trait = "0.1.88" candid = { version = "0.10.13" } canhttp = { version = "0.4.0", path = "canhttp" } ciborium = "0.2.2" +derive_more = { version = "2.0.1", features = ["from", "try_unwrap", "unwrap"] } futures-channel = "0.3.31" futures-util = "0.3.31" http = "1.3.1" diff --git a/canhttp/Cargo.toml b/canhttp/Cargo.toml index c494a8c..6c7a81c 100644 --- a/canhttp/Cargo.toml +++ b/canhttp/Cargo.toml @@ -14,17 +14,19 @@ documentation = "https://docs.rs/canhttp" [features] default = ["http"] http = ["dep:http", "dep:num-traits", "dep:tower-layer"] -json = ["http", "dep:serde", "dep:serde_json"] +json = ["dep:derive_more", "dep:http", "dep:serde", "dep:serde_json"] multi = ["dep:ciborium", "dep:sha2", "dep:futures-channel", "dep:serde"] [dependencies] assert_matches = { workspace = true } ciborium = { workspace = true, optional = true } +derive_more = { workspace = true, optional = true } futures-channel = { workspace = true, optional = true } futures-util = { workspace = true } http = { workspace = true, optional = true } ic-cdk = { workspace = true } ic-error-types = { workspace = true } +itertools = { workspace = true } num-traits = { workspace = true, optional = true } pin-project = { workspace = true } serde = { workspace = true, optional = true } diff --git a/canhttp/src/http/json/id.rs b/canhttp/src/http/json/id.rs index 562028b..045434d 100644 --- a/canhttp/src/http/json/id.rs +++ b/canhttp/src/http/json/id.rs @@ -1,13 +1,15 @@ use serde::{Deserialize, Serialize}; -use std::fmt::{Display, Formatter}; -use std::num::ParseIntError; -use std::str::FromStr; +use std::{ + fmt::{Display, Formatter}, + num::ParseIntError, + str::FromStr, +}; /// An identifier established by the Client that MUST contain a String, Number, or NULL value if included. /// /// If it is not included it is assumed to be a notification. /// The value SHOULD normally not be Null. -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(untagged)] pub enum Id { /// Numeric ID. diff --git a/canhttp/src/http/json/mod.rs b/canhttp/src/http/json/mod.rs index 2aa5b7d..78d8f35 100644 --- a/canhttp/src/http/json/mod.rs +++ b/canhttp/src/http/json/mod.rs @@ -51,7 +51,7 @@ //! ``` //! //! [`Service`]: tower::Service - +use crate::convert::CreateResponseFilter; use crate::{ convert::{ ConvertRequest, ConvertRequestLayer, ConvertResponse, ConvertResponseLayer, @@ -61,15 +61,16 @@ use crate::{ }; pub use id::{ConstantSizeId, Id}; pub use request::{ - HttpJsonRpcRequest, JsonRequestConversionError, JsonRequestConverter, JsonRpcRequest, + BatchJsonRpcRequest, HttpBatchJsonRpcRequest, HttpJsonRpcRequest, JsonRequestConversionError, + JsonRequestConverter, JsonRpcRequest, }; pub use response::{ - ConsistentJsonRpcIdFilter, ConsistentResponseIdFilterError, CreateJsonRpcIdFilter, - HttpJsonRpcResponse, JsonResponseConversionError, JsonResponseConverter, JsonRpcError, - JsonRpcResponse, JsonRpcResult, + BatchJsonRpcResponse, ConsistentJsonRpcIdFilter, ConsistentResponseIdFilterError, + CreateJsonRpcIdFilter, HttpBatchJsonRpcResponse, HttpJsonRpcResponse, + JsonResponseConversionError, JsonResponseConverter, JsonRpcError, JsonRpcResponse, }; use serde::{de::DeserializeOwned, Serialize}; -use std::marker::PhantomData; +use std::{fmt::Debug, marker::PhantomData}; use tower_layer::{Layer, Stack}; pub use version::Version; @@ -132,21 +133,77 @@ where } } -/// Middleware that combines a [`HttpConversionLayer`], a [`JsonConversionLayer`] to create -/// an JSON-RPC over HTTP [`Service`]. +/// Middleware that combines an [`HttpConversionLayer`] and a [`JsonConversionLayer`] to create +/// a JSON-RPC over HTTP [`Service`]. +/// +/// This middleware can be used either with regular JSON-RPC requests and responses (i.e. +/// [`JsonRpcRequest`] and [`JsonRpcResponse`]) or with batch JSON-RPC requests and responses +/// (i.e. [`BatchJsonRpcRequest`] and [`BatchJsonRpcResponse`]). /// /// This middleware includes a [`ConsistentJsonRpcIdFilter`], which ensures that each response /// carries a valid JSON-RPC ID matching the corresponding request ID. This guarantees that the /// [`Service`] complies with the [JSON-RPC 2.0 specification]. /// +/// # Examples +/// +/// Create a simple JSON-RPC over HTTP client. +/// ``` +/// use canhttp::{ +/// Client, +/// http::json::{HttpJsonRpcRequest, HttpJsonRpcResponse, JsonRpcHttpLayer} +/// }; +/// use serde::{de::DeserializeOwned, Serialize}; +/// use std::fmt::Debug; +/// use tower::{BoxError, Service, ServiceBuilder}; +/// +/// fn client() -> impl Service< +/// HttpJsonRpcRequest, +/// Response = HttpJsonRpcResponse, +/// Error = BoxError +/// > +/// where +/// Params: Debug + Serialize, +/// Result: Debug + DeserializeOwned, +/// { +/// ServiceBuilder::new() +/// .layer(JsonRpcHttpLayer::new()) +/// .service(Client::new_with_box_error()) +/// } +/// ``` +/// +/// Create a simple batch JSON-RPC over HTTP client. +/// ``` +/// use canhttp::{ +/// Client, +/// http::json::{HttpBatchJsonRpcRequest, HttpBatchJsonRpcResponse, JsonRpcHttpLayer} +/// }; +/// use serde::{de::DeserializeOwned, Serialize}; +/// use std::fmt::Debug; +/// use tower::{BoxError, Service, ServiceBuilder}; +/// +/// fn client() -> impl Service< +/// HttpBatchJsonRpcRequest, +/// Response = HttpBatchJsonRpcResponse, +/// Error = BoxError +/// > +/// where +/// Params: Debug + Serialize, +/// Result: Debug + DeserializeOwned, +/// { +/// ServiceBuilder::new() +/// .layer(JsonRpcHttpLayer::new()) +/// .service(Client::new_with_box_error()) +/// } +/// ``` +/// /// [`Service`]: tower::Service /// [JSON-RPC 2.0 specification]: https://www.jsonrpc.org/specification #[derive(Debug)] -pub struct JsonRpcHttpLayer { - _marker: PhantomData<(Params, Result)>, +pub struct JsonRpcHttpLayer { + _marker: PhantomData<(Request, Response)>, } -impl JsonRpcHttpLayer { +impl JsonRpcHttpLayer { /// Returns a new [`JsonRpcHttpLayer`]. pub fn new() -> Self { Self { @@ -155,7 +212,7 @@ impl JsonRpcHttpLayer { } } -impl Clone for JsonRpcHttpLayer { +impl Clone for JsonRpcHttpLayer { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -163,32 +220,34 @@ impl Clone for JsonRpcHttpLayer { } } -impl Default for JsonRpcHttpLayer { +impl Default for JsonRpcHttpLayer { fn default() -> Self { Self::new() } } -impl Layer for JsonRpcHttpLayer +impl Layer for JsonRpcHttpLayer where - Params: Serialize, - Result: DeserializeOwned, + Request: Serialize, + Response: DeserializeOwned, + CreateJsonRpcIdFilter: + CreateResponseFilter, http::Response>, { type Service = FilterResponse< ConvertResponse< ConvertRequest< ConvertResponse, HttpResponseConverter>, - JsonRequestConverter>, + JsonRequestConverter, >, - JsonResponseConverter>, + JsonResponseConverter, >, - CreateJsonRpcIdFilter, + CreateJsonRpcIdFilter, >; fn layer(&self, inner: S) -> Self::Service { stack( HttpConversionLayer, - JsonConversionLayer::, JsonRpcResponse>::new(), + JsonConversionLayer::::new(), CreateResponseFilterLayer::new(CreateJsonRpcIdFilter::new()), ) .layer(inner) diff --git a/canhttp/src/http/json/request.rs b/canhttp/src/http/json/request.rs index 8357499..b44b2b2 100644 --- a/canhttp/src/http/json/request.rs +++ b/canhttp/src/http/json/request.rs @@ -1,8 +1,11 @@ -use crate::convert::Convert; -use crate::http::json::{ConstantSizeId, Id, Version}; -use crate::http::HttpRequest; -use http::header::CONTENT_TYPE; -use http::HeaderValue; +use crate::{ + convert::Convert, + http::{ + json::{ConstantSizeId, Id, Version}, + HttpRequest, + }, +}; +use http::{header::CONTENT_TYPE, HeaderValue}; use serde::{Deserialize, Serialize}; use std::marker::PhantomData; use thiserror::Error; @@ -79,10 +82,20 @@ fn add_content_type_header_if_missing(mut request: HttpRequest) -> HttpRequest { request } -/// JSON-RPC request. +/// Batch JSON-RPC request over HTTP. +pub type HttpBatchJsonRpcRequest = http::Request>; + +/// JSON-RPC request over HTTP. pub type HttpJsonRpcRequest = http::Request>; -/// Body for all JSON-RPC requests, see the [specification](https://www.jsonrpc.org/specification). +/// Batch JSON-RPC request body, see the [specification]. +/// +/// [specification]: https://www.jsonrpc.org/specification +pub type BatchJsonRpcRequest = Vec>; + +/// JSON-RPC request body, see the [specification]. +/// +/// [specification]: https://www.jsonrpc.org/specification #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct JsonRpcRequest { jsonrpc: Version, diff --git a/canhttp/src/http/json/response.rs b/canhttp/src/http/json/response.rs index eb55597..ed94230 100644 --- a/canhttp/src/http/json/response.rs +++ b/canhttp/src/http/json/response.rs @@ -1,13 +1,23 @@ -use crate::convert::{Convert, CreateResponseFilter, Filter}; -use crate::http::json::{HttpJsonRpcRequest, Id, Version}; -use crate::http::HttpResponse; -use assert_matches::assert_matches; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use crate::{ + convert::{Convert, CreateResponseFilter, Filter}, + http::{ + json::{ + BatchJsonRpcRequest, HttpBatchJsonRpcRequest, HttpJsonRpcRequest, Id, JsonRpcRequest, + Version, + }, + HttpResponse, + }, +}; +use itertools::{Either, Itertools}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; -use std::marker::PhantomData; +use std::collections::BTreeSet; +use std::{collections::BTreeMap, fmt::Debug, marker::PhantomData}; use thiserror::Error; +#[cfg(test)] +mod tests; + /// Convert responses of type [HttpResponse] into [`http::Response`], where `T` is `Deserialize` /// by parsing the response body as JSON text bytes. #[derive(Debug)] @@ -78,12 +88,22 @@ where /// JSON-RPC response over HTTP. pub type HttpJsonRpcResponse = http::Response>; +/// Batch JSON-RPC response body, see the [specification]. +/// +/// [specification]: https://www.jsonrpc.org/specification +pub type BatchJsonRpcResponse = Vec>; + +/// Batch JSON-RPC response over HTTP. +pub type HttpBatchJsonRpcResponse = http::Response>>; + /// A specialized [`Result`] error type for JSON-RPC responses. /// /// [`Result`]: enum@std::result::Result pub type JsonRpcResult = Result; -/// JSON-RPC response. +/// JSON-RPC response body, see the [specification]. +/// +/// [specification]: https://www.jsonrpc.org/specification #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct JsonRpcResponse { jsonrpc: Version, @@ -244,6 +264,18 @@ impl JsonRpcError { pub fn is_invalid_request(&self) -> bool { self.code == -32600 } + + /// An invalid request JSON-RPC error object, + /// as defined in the [JSON-RPC specification](https://www.jsonrpc.org/specification). + pub fn invalid_request() -> Self { + Self::new(-32600, "Invalid Request") + } + + /// A parse error JSON-RPC error object, + /// as defined in the [JSON-RPC specification](https://www.jsonrpc.org/specification). + pub fn parse_error() -> Self { + Self::new(-32700, "Parse error") + } } /// Error returned by the [`ConsistentJsonRpcIdFilter`]. @@ -261,14 +293,26 @@ pub enum ConsistentResponseIdFilterError { /// ID from the response. response_id: Id, }, + /// IDs in the response either contain unexpected IDs or are missing some request IDs + #[error( + "Inconsistent identifiers: expected batch response IDs to be {request_ids:?}, but got {response_ids:?}" + )] + InconsistentBatchIds { + /// Response status code. + status: u16, + /// IDs from the request. + request_ids: Vec, + /// IDs from the response. + response_ids: Vec, + }, } /// Create [`ConsistentJsonRpcIdFilter`] for each request. -pub struct CreateJsonRpcIdFilter { - _marker: PhantomData<(I, O)>, +pub struct CreateJsonRpcIdFilter { + _marker: PhantomData<(Request, Response)>, } -impl CreateJsonRpcIdFilter { +impl CreateJsonRpcIdFilter { /// Create a new instance of [`CreateJsonRpcIdFilter`] pub fn new() -> Self { Self { @@ -277,7 +321,7 @@ impl CreateJsonRpcIdFilter { } } -impl Clone for CreateJsonRpcIdFilter { +impl Clone for CreateJsonRpcIdFilter { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -285,78 +329,210 @@ impl Clone for CreateJsonRpcIdFilter { } } -impl Default for CreateJsonRpcIdFilter { +impl Default for CreateJsonRpcIdFilter { fn default() -> Self { Self::new() } } impl CreateResponseFilter, HttpJsonRpcResponse> - for CreateJsonRpcIdFilter + for CreateJsonRpcIdFilter, JsonRpcResponse> +where + JsonRpcRequest: Serialize, + JsonRpcResponse: DeserializeOwned, +{ + type Filter = ConsistentJsonRpcIdFilter, JsonRpcResponse>; + type Error = ConsistentResponseIdFilterError; + + fn create_filter(&self, request: &HttpJsonRpcRequest) -> Self::Filter { + let request_id = expected_response_id(request.body()); + ConsistentJsonRpcIdFilter::new(vec![request_id]) + } +} + +impl CreateResponseFilter, HttpBatchJsonRpcResponse> + for CreateJsonRpcIdFilter, BatchJsonRpcResponse> +where + BatchJsonRpcRequest: Serialize, + BatchJsonRpcResponse: DeserializeOwned, { - type Filter = ConsistentJsonRpcIdFilter; + type Filter = ConsistentJsonRpcIdFilter, BatchJsonRpcResponse>; type Error = ConsistentResponseIdFilterError; - fn create_filter(&self, request: &HttpJsonRpcRequest) -> ConsistentJsonRpcIdFilter { - ConsistentJsonRpcIdFilter::new(request.body().id().clone()) + /// # Panics + /// + /// This implementation panics in the following cases: + /// * The JSON-RPC batch is empty. + /// * The IDs of the requests in the JSON-RPC batch are not unique. + fn create_filter(&self, request: &HttpBatchJsonRpcRequest) -> Self::Filter { + let requests = request.body(); + + assert!(!requests.is_empty(), "Expected batch to not be empty"); + + let request_ids = requests + .iter() + .map(expected_response_id) + .collect::>(); + assert_eq!( + BTreeSet::from_iter(request_ids.iter()).len(), + requests.len(), + "Expected request IDs to be unique, but got: {request_ids:?}" + ); + + ConsistentJsonRpcIdFilter::new(request_ids) } } /// Ensure that the ID of the response is consistent with the one from the request /// that is stored internally. -pub struct ConsistentJsonRpcIdFilter { - request_id: Id, - _marker: PhantomData, +pub struct ConsistentJsonRpcIdFilter { + request_ids: Vec, + _marker: PhantomData<(Request, Response)>, } -impl ConsistentJsonRpcIdFilter { - /// Creates a new JSON-RPC filter to ensure that the ID of the response matches the one given in parameter. +impl ConsistentJsonRpcIdFilter { + /// Creates a new JSON-RPC filter to ensure that the response IDs match the given request + /// IDs. /// /// # Panics /// - /// The method panics if the given ID is [`Id::Null`]. + /// The method panics if any of the given IDs is [`Id::Null`]. /// This is because a request ID with value [`Id::Null`] indicates a Notification, /// which indicates that the client does not care about the response (see the /// JSON-RPC [specification](https://www.jsonrpc.org/specification)). - pub fn new(request_id: Id) -> Self { - assert_matches!( - request_id, - Id::Number(_) | Id::String(_), - "ERROR: a null request ID is a notification that indicates that the client is not interested in the response." - ); + fn new(request_ids: Vec) -> Self { Self { - request_id, + request_ids, _marker: PhantomData, } } } -impl Filter> for ConsistentJsonRpcIdFilter { +impl Filter> + for ConsistentJsonRpcIdFilter, JsonRpcResponse> +where + JsonRpcRequest: Serialize, + JsonRpcResponse: DeserializeOwned, +{ type Error = ConsistentResponseIdFilterError; fn filter( &mut self, response: HttpJsonRpcResponse, ) -> Result, Self::Error> { - let request_id = &self.request_id; - let (response_id, result) = response.body().as_parts(); - if request_id == response_id { - return Ok(response); + // From the [JSON-RPC specification](https://www.jsonrpc.org/specification): + // > If there was an error in detecting the id in the Request object + // > (e.g. Parse error/Invalid Request), it MUST be Null. + fn should_have_null_id(response: &JsonRpcResponse) -> bool { + let (response_id, result) = response.as_parts(); + response_id.is_null() + && result.is_err_and(|e| e.is_parse_error() || e.is_invalid_request()) } - if response_id.is_null() - && result.is_err_and(|e| e.is_parse_error() || e.is_invalid_request()) - { - // From the [JSON-RPC specification](https://www.jsonrpc.org/specification): - // If there was an error in detecting the id in the Request object - // (e.g. Parse error/Invalid Request), it MUST be Null. - return Ok(response); + let request_id = self + .request_ids + .iter() + .exactly_one() + .expect("Expected request ID to contain only a single ID"); + let response_id = response.body().id(); + if request_id == response_id || should_have_null_id(response.body()) { + Ok(response) + } else { + Err(ConsistentResponseIdFilterError::InconsistentId { + status: response.status().into(), + request_id: request_id.clone(), + response_id: response_id.clone(), + }) } + } +} + +impl Filter> + for ConsistentJsonRpcIdFilter, BatchJsonRpcResponse> +where + BatchJsonRpcRequest: Serialize, + BatchJsonRpcResponse: DeserializeOwned, +{ + type Error = ConsistentResponseIdFilterError; - Err(ConsistentResponseIdFilterError::InconsistentId { - status: response.status().as_u16(), - request_id: request_id.clone(), - response_id: response_id.clone(), - }) + fn filter( + &mut self, + response: HttpBatchJsonRpcResponse, + ) -> Result, Self::Error> { + let (head, responses) = response.into_parts(); + let response_ids: Vec = responses + .iter() + .map(|response| response.id()) + .cloned() + .collect(); + let correlated_responses = try_order_responses_by_id(&self.request_ids, responses) + .ok_or_else(|| ConsistentResponseIdFilterError::InconsistentBatchIds { + status: head.status.into(), + request_ids: self.request_ids.to_vec(), + response_ids, + })?; + Ok(http::Response::from_parts(head, correlated_responses)) + } +} + +fn expected_response_id(request: &JsonRpcRequest) -> Id { + match request.id() { + Id::Null => panic!("ERROR: a null request ID is a notification that indicates that the client is not interested in the response."), + id @ (Id::Number(_) | Id::String(_)) => id.clone() + } +} + +fn try_order_responses_by_id( + request_ids: &[Id], + responses: Vec>, +) -> Option>> { + if request_ids.len() != responses.len() { + return None; + } + + let (responses_with_null_id, mut responses_with_non_null_id): (Vec<_>, BTreeMap<_, _>) = + responses + .into_iter() + .partition_map(|response| match response.id() { + Id::Null => Either::Left(response), + _ => Either::Right((response.id().clone(), response)), + }); + + // From the [JSON-RPC specification](https://www.jsonrpc.org/specification): + // > If there was an error in detecting the id in the Request object + // > (e.g. Parse error/Invalid Request), it MUST be Null. + // However, a parse error should result in a single error object for the response: + // > If the batch rpc call itself fails to be recognized as an valid JSON or as an Array + // > with at least one value, the response from the Server MUST be a single Response object. + // Hence, a null ID must only occur in the event of an invalid request error. + if !responses_with_null_id + .iter() + .all(|response| response.as_result().is_err_and(|e| e.is_invalid_request())) + { + return None; + } + let num_responses_with_null_id = responses_with_null_id.len(); + + // Correlate responses to requests by ID + let mut num_missing_request_ids = 0; + let correlated_responses = request_ids + .iter() + .map( + |request_id| match responses_with_non_null_id.remove(request_id) { + Some(response) => response, + None => { + num_missing_request_ids += 1; + JsonRpcResponse::from_parts(Id::Null, Err(JsonRpcError::invalid_request())) + } + }, + ) + .collect::>(); + + // Make sure there are no missing or unexpected request IDs, i.e., the only missing request IDs + // are those for which the response is an invalid request error. + if num_responses_with_null_id != num_missing_request_ids { + return None; } + + Some(correlated_responses) } diff --git a/canhttp/src/http/json/response/tests.rs b/canhttp/src/http/json/response/tests.rs new file mode 100644 index 0000000..2333f81 --- /dev/null +++ b/canhttp/src/http/json/response/tests.rs @@ -0,0 +1,202 @@ +use super::{try_order_responses_by_id, Id, JsonRpcError, JsonRpcResponse}; +use crate::http::json::response::JsonRpcResult; +use proptest::{ + arbitrary::any, + collection::{btree_set, vec}, + prelude::{Just, Strategy}, + prop_assert, prop_assert_eq, prop_oneof, proptest, +}; +use serde_json::json; +use std::{iter, ops::Range}; + +mod json_rpc_batch_response_id_validation_tests { + use super::*; + + #[test] + fn should_succeed_for_empty_response() { + let result = try_order_responses_by_id::(&[], Vec::new()); + + assert!(result.is_some()); + assert_eq!(result.unwrap(), Vec::new()); + } + + proptest! { + #[test] + fn should_succeed_with_responses_in_any_order( + (shuffled_responses, request_ids) in arbitrary_responses_with_unique_nonnull_ids(2..10) + .prop_flat_map(|responses| { + let request_ids = response_ids(&responses); + (Just(responses).prop_shuffle(), Just(request_ids)) + }) + ) { + let result = try_order_responses_by_id(&request_ids, shuffled_responses); + + prop_assert!(result.is_some()); + prop_assert_eq!(request_ids, response_ids(&result.unwrap())); + } + } + + proptest! { + #[test] + fn should_succeed_with_invalid_request_errors_in_any_order( + (shuffled_responses, request_ids) in arbitrary_responses_with_null_ids(2..10) + .prop_flat_map(|responses| { + let request_ids = response_ids(&responses); + (Just(responses).prop_shuffle(), Just(request_ids)) + }) + ) { + let result = try_order_responses_by_id(&request_ids, shuffled_responses); + + prop_assert!(result.is_some()); + prop_assert_eq!(request_ids, response_ids(&result.unwrap())); + } + } + + proptest! { + #[test] + fn should_return_error_for_unexpected_id_in_response( + (mut responses, unexpected_id, i) in arbitrary_responses_with_unique_nonnull_ids(2..10) + .prop_flat_map(|mut responses| { + let unexpected_id = responses.pop().unwrap().id().clone(); + let batch_size = responses.len(); + (Just(responses), Just(unexpected_id), 0..batch_size) + }) + ) { + let request_ids = response_ids(&responses); + + // Ensure one of the response IDs is not in the request IDs, + set_id(&mut responses[i], unexpected_id); + + let result = try_order_responses_by_id(&request_ids, responses); + + prop_assert!(result.is_none()); + } + } + + proptest! { + #[test] + fn should_return_error_for_duplicate_id_in_response( + mut responses in arbitrary_responses_with_unique_nonnull_ids(2..10) + ) { + let n = responses.len(); + let request_ids = response_ids(&responses); + + // Duplicate the second last response ID + let id = responses[n - 2].id().clone(); + set_id(&mut responses[n - 1], id); + + let result = try_order_responses_by_id(&request_ids, responses); + + prop_assert!(result.is_none()); + } + } + + proptest! { + #[test] + fn should_return_error_for_too_few_responses( + mut responses in arbitrary_responses_with_unique_nonnull_ids(2..10) + ) { + let request_ids = response_ids(&responses); + + // Ensure there is one more request ID than responses + responses.remove(responses.len() - 1); + + let result = try_order_responses_by_id(&request_ids, responses); + + prop_assert!(result.is_none()); + } + } + + proptest! { + #[test] + fn should_return_error_for_too_many_responses( + responses in arbitrary_responses_with_unique_nonnull_ids(2..10) + ) { + let mut request_ids = response_ids(&responses); + + // Ensure there is one more response than expected request IDs + request_ids.remove(request_ids.len() - 1); + + let result = try_order_responses_by_id(&request_ids, responses); + + prop_assert!(result.is_none()); + } + } + + proptest! { + #[test] + fn should_return_error_for_response_with_null_id_that_is_not_invalid_request_error( + mut responses in arbitrary_responses_with_unique_nonnull_ids(2..10) + ) { + let n = responses.len(); + let request_ids = response_ids(&responses); + + // Ensure there is one more request ID than responses + set_id(&mut responses[n - 1], Id::Null); + + let result = try_order_responses_by_id(&request_ids, responses); + + prop_assert!(result.is_none()); + } + } + + fn response_ids(responses: &[JsonRpcResponse]) -> Vec { + responses + .iter() + .map(|response| response.id()) + .cloned() + .collect() + } + + fn arbitrary_responses_with_null_ids( + size: Range, + ) -> impl Strategy>> { + ( + vec(Just(Id::Null), size.clone()), + vec(Just(Err(JsonRpcError::invalid_request())), size.clone()), + btree_set(arbitrary_nonnull_id(), size.clone()).prop_map(Vec::from_iter), + vec(arbitrary_json_rpc_result(), size), + ) + .prop_map(|(null_ids, invalid_request_errors, unique_ids, results)| { + iter::zip(null_ids, invalid_request_errors) + .chain(iter::zip(unique_ids, results)) + .map(|(id, result)| JsonRpcResponse::from_parts(id, result)) + .collect() + }) + .prop_shuffle() + } + + fn arbitrary_responses_with_unique_nonnull_ids( + size: Range, + ) -> impl Strategy>> { + ( + // Ensure the response IDs are unique + btree_set(arbitrary_nonnull_id(), size.clone()).prop_map(Vec::from_iter), + vec(arbitrary_json_rpc_result(), size), + ) + .prop_map(|(ids, results)| { + iter::zip(ids, results) + .map(|(id, result)| JsonRpcResponse::from_parts(id, result)) + .collect() + }) + } + + fn arbitrary_json_rpc_result() -> impl Strategy> { + prop_oneof![ + (".*", any::()) + .prop_map(|(key, value)| json!({key: value})) + .prop_map(Ok), + (any::(), ".*") + .prop_map(|(code, message)| JsonRpcError::new(code, message)) + .prop_map(Err), + ] + } + + fn arbitrary_nonnull_id() -> impl Strategy { + prop_oneof![any::().prop_map(Id::Number), ".*".prop_map(Id::String),] + } + + fn set_id(response: &mut JsonRpcResponse, id: Id) { + *response = JsonRpcResponse::from_parts(id, response.clone().into_result()); + } +} diff --git a/canhttp/src/http/json/tests.rs b/canhttp/src/http/json/tests.rs index 1e03b95..995986b 100644 --- a/canhttp/src/http/json/tests.rs +++ b/canhttp/src/http/json/tests.rs @@ -1,16 +1,31 @@ -use crate::http::json::{JsonConversionLayer, JsonRequestConverter, JsonResponseConverter}; -use crate::http::{HttpRequest, HttpResponse}; -use crate::ConvertServiceBuilder; +use crate::{ + http::{ + json::{ + ConstantSizeId, CreateJsonRpcIdFilter, HttpBatchJsonRpcRequest, + HttpBatchJsonRpcResponse, HttpJsonRpcRequest, HttpJsonRpcResponse, Id, + JsonConversionLayer, JsonRequestConverter, JsonResponseConverter, JsonRpcError, + JsonRpcRequest, JsonRpcResponse, Version, + }, + HttpRequest, HttpResponse, + }, + ConvertServiceBuilder, +}; +use assert_matches::assert_matches; use http::HeaderValue; +use itertools::Itertools; +use proptest::{prelude::any, prop_assert_eq, proptest}; +use serde::{de::DeserializeOwned, Serialize}; use serde_json::json; +use std::{ + fmt::Debug, + hash::{DefaultHasher, Hash}, +}; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; +const URL: &str = "https://internetcomputer.org/"; + mod json_rpc { - use crate::http::json::{Id, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Version}; - use assert_matches::assert_matches; - use serde::de::DeserializeOwned; - use serde_json::json; - use std::fmt::Debug; + use super::*; #[test] fn should_parse_null_id() { @@ -106,9 +121,7 @@ mod json_rpc { } mod constant_size_id { - use crate::http::json::{ConstantSizeId, Id}; - use proptest::prelude::any; - use proptest::{prop_assert_eq, proptest}; + use super::*; #[test] fn should_add_padding_to_the_left() { @@ -148,7 +161,7 @@ mod constant_size_id { #[tokio::test] async fn should_convert_json_request() { - let url = "https://internetcomputer.org/"; + let url = URL; let mut service = ServiceBuilder::new() .convert_request(JsonRequestConverter::::new()) .service_fn(echo_request); @@ -166,7 +179,7 @@ async fn should_convert_json_request() { #[tokio::test] async fn should_add_content_type_header_if_missing() { - let url = "https://internetcomputer.org/"; + let url = URL; let mut service = ServiceBuilder::new() .convert_request(JsonRequestConverter::::new()) .service_fn(echo_request); @@ -240,7 +253,7 @@ async fn should_convert_both_request_and_response() { .await .unwrap() .call( - http::Request::post("https://internetcomputer.org/") + http::Request::post(URL) .body(json!({"foo": "bar"})) .unwrap(), ) @@ -251,13 +264,8 @@ async fn should_convert_both_request_and_response() { } mod filter_json_rpc_id { - use crate::http::json::{ - CreateJsonRpcIdFilter, HttpJsonRpcRequest, Id, JsonRpcError, JsonRpcRequest, - JsonRpcResponse, - }; - use crate::ConvertServiceBuilder; - use serde_json::json; - use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; + use super::*; + use std::hash::Hasher; #[tokio::test] async fn should_check_json_rpc_id_is_consistent() { @@ -266,7 +274,7 @@ mod filter_json_rpc_id { response: JsonRpcResponse, expected_result: Result<(), String>, ) { - let request = http::Request::post("https://internetcomputer.org/") + let request = http::Request::post(URL) .body( JsonRpcRequest::new("foo", json!(["param1", "param2"])) .with_id(request_id.clone()), @@ -278,19 +286,9 @@ mod filter_json_rpc_id { Ok::<_, BoxError>(http::Response::new(response.clone())) }); - match service.ready().await.unwrap().call(request).await { - Ok(service_response) => { - assert_eq!(expected_result, Ok(())); - assert_eq!(service_response.into_body(), response); - } - Err(error) => { - let expected_error = expected_result.expect_err("expected error"); - assert!( - error.to_string().contains(&expected_error), - "Expected error: {expected_error}, but got {error}", - ) - } - } + let service_result = service.ready().await.unwrap().call(request).await; + + assert_expected_result(service_result, expected_result.map(|_| response)); } check( @@ -305,7 +303,6 @@ mod filter_json_rpc_id { Err("expected response ID".to_string()), ) .await; - check( Id::from(42_u64), JsonRpcResponse::from_error( @@ -334,20 +331,92 @@ mod filter_json_rpc_id { .await; } + #[tokio::test] + async fn should_check_json_rpc_batch_ids_are_consistent() { + async fn check( + request_ids: Vec, + responses: Vec>, + expected_result: Result<(), String>, + ) { + assert_eq!(request_ids.len(), responses.len()); + + let request = http::Request::post(URL) + .body( + request_ids + .into_iter() + .enumerate() + .map(|(i, id)| { + JsonRpcRequest::new("foo", json!(["param1", {"param2": i}])).with_id(id) + }) + .collect(), + ) + .unwrap(); + let mut service = ServiceBuilder::new() + .filter_response(CreateJsonRpcIdFilter::new()) + .map_response(shuffle_json_rpc_batch_responses) + .service_fn( + |_request: HttpBatchJsonRpcRequest| async { + Ok::<_, BoxError>(http::Response::new(responses.clone())) + }, + ); + + let service_result = service.ready().await.unwrap().call(request).await; + + assert_expected_result(service_result, expected_result.map(|_| responses)); + } + + check( + vec![Id::from(42_u64), Id::from(43_u64), Id::from(44_u64)], + vec![ + JsonRpcResponse::from_ok(Id::from(42_u64), json!(1)), + JsonRpcResponse::from_ok(Id::from(43_u64), json!(2)), + JsonRpcResponse::from_error( + Id::Null, + JsonRpcError { + code: -32600, + message: "Invalid Request".to_string(), + data: None, + }, + ), + ], + Ok(()), + ) + .await; + check( + vec![Id::from(42_u64), Id::from(43_u64), Id::from(44_u64)], + vec![ + JsonRpcResponse::from_ok(Id::from(42_u64), json!(1)), + JsonRpcResponse::from_ok(Id::from(43_u64), json!(2)), + JsonRpcResponse::from_error( + Id::Null, + JsonRpcError { + code: -32601, + message: "Method not found".to_string(), + data: None, + }, + ), + ], + Err("expected batch response IDs".to_string()), + ) + .await; + check( + vec![Id::from(42_u64), Id::from(43_u64), Id::from(44_u64)], + vec![ + JsonRpcResponse::from_ok(Id::from(42_u64), json!(1)), + JsonRpcResponse::from_ok(Id::from(43_u64), json!(1)), + JsonRpcResponse::from_ok(Id::from(45_u64), json!(1)), + ], + Err("expected batch response IDs".to_string()), + ) + .await; + } + #[tokio::test] #[should_panic(expected = "ERROR: a null request ID")] async fn should_panic_when_request_id_null() { let mut service = ServiceBuilder::new() .filter_response(CreateJsonRpcIdFilter::new()) - .service_fn( - |request: HttpJsonRpcRequest| async move { - let id = request.body().id(); - Ok::<_, BoxError>(http::Response::new(JsonRpcResponse::from_ok( - id.clone(), - json!("echo"), - ))) - }, - ); + .service_fn(echo_json_rpc_request_id); let request = JsonRpcRequest::new("foo", json!(["param1", "param2"])).with_id(Id::Null); @@ -355,14 +424,135 @@ mod filter_json_rpc_id { .ready() .await .unwrap() - .call( - http::Request::post("https://internetcomputer.org/") - .body(request.clone()) - .unwrap(), - ) + .call(http::Request::post(URL).body(request).unwrap()) .await .unwrap(); } + + #[tokio::test] + #[should_panic(expected = "Expected batch to not be empty")] + async fn should_panic_when_json_rpc_batch_empty() { + let mut service = ServiceBuilder::new() + .filter_response(CreateJsonRpcIdFilter::new()) + .service_fn(echo_json_rpc_batch_request_ids); + + let _response = service + .ready() + .await + .unwrap() + .call(http::Request::post(URL).body(vec![]).unwrap()) + .await + .unwrap(); + } + + #[tokio::test] + #[should_panic(expected = "Expected request IDs to be unique")] + async fn should_panic_when_request_ids_not_unique() { + let mut service = ServiceBuilder::new() + .filter_response(CreateJsonRpcIdFilter::new()) + .service_fn(echo_json_rpc_batch_request_ids); + + let request = vec![ + JsonRpcRequest::new("foo", json!(["param1", "param2"])).with_id(Id::from(100_u64)), + JsonRpcRequest::new("bar", json!(["param3", "param4"])).with_id(Id::from(101_u64)), + JsonRpcRequest::new("bar", json!(["param3", "param4"])).with_id(Id::from(101_u64)), + ]; + + let _response = service + .ready() + .await + .unwrap() + .call(http::Request::post(URL).body(request).unwrap()) + .await + .unwrap(); + } + + #[tokio::test] + #[should_panic(expected = "ERROR: a null request ID")] + async fn should_panic_when_json_rpc_batch_contains_null_id() { + let mut service = ServiceBuilder::new() + .filter_response(CreateJsonRpcIdFilter::new()) + .service_fn(echo_json_rpc_batch_request_ids); + + let request = vec![ + JsonRpcRequest::new("foo", json!(["param1", "param2"])).with_id(Id::from(100_u64)), + JsonRpcRequest::new("bar", json!(["param3", "param4"])).with_id(Id::from(101_u64)), + JsonRpcRequest::new("bar", json!(["param3", "param4"])).with_id(Id::Null), + ]; + + let _response = service + .ready() + .await + .unwrap() + .call(http::Request::post(URL).body(request).unwrap()) + .await + .unwrap(); + } + + fn assert_expected_result( + service_result: Result, BoxError>, + expected_result: Result, + ) { + match expected_result { + Ok(expected_response) => { + let service_response = service_result + .unwrap_or_else(|error| panic!("Expected ok, but got: {error}")) + .into_body(); + assert_eq!( + service_response, expected_response, + "Expected response: {expected_response:?}, but got {service_response:?}" + ); + } + Err(expected_error) => { + let service_error = match service_result { + Ok(response) => { + panic!("Expected error, but got {:?}", response.into_body()) + } + Err(error) => error, + }; + assert!( + service_error.to_string().contains(&expected_error), + "Expected error: {expected_error}, but got {service_error}", + ); + } + } + } + + async fn echo_json_rpc_request_id( + request: HttpJsonRpcRequest, + ) -> Result, BoxError> { + Ok(http::Response::new(JsonRpcResponse::from_ok( + request.body().id().clone(), + json!("echo"), + ))) + } + + fn shuffle_json_rpc_batch_responses( + response: HttpBatchJsonRpcResponse, + ) -> HttpBatchJsonRpcResponse { + response.map(|responses| { + responses + .into_iter() + // Sort responses in a JSON-RPC batch by hash to simulate random shuffling + .sorted_by_key(|r| { + let mut hasher = DefaultHasher::new(); + serde_json::to_string(r).unwrap().hash(&mut hasher); + hasher.finish() + }) + .collect() + }) + } + + async fn echo_json_rpc_batch_request_ids( + request: HttpBatchJsonRpcRequest, + ) -> Result, BoxError> { + let responses = request + .into_body() + .iter() + .map(|request| JsonRpcResponse::from_ok(request.id().clone(), json!("echo"))) + .collect(); + Ok(http::Response::new(responses)) + } } async fn echo_request(request: HttpRequest) -> Result { diff --git a/examples/json_rpc_canister/src/main.rs b/examples/json_rpc_canister/src/main.rs index c86c80b..0cf8541 100644 --- a/examples/json_rpc_canister/src/main.rs +++ b/examples/json_rpc_canister/src/main.rs @@ -1,8 +1,12 @@ //! Example of a canister using `canhttp` to issue JSON-RPC HTTP requests. +use candid::{CandidType, Deserialize}; use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, - http::json::{HttpJsonRpcRequest, HttpJsonRpcResponse, Id, JsonRpcHttpLayer, JsonRpcRequest}, + http::json::{ + HttpBatchJsonRpcRequest, HttpBatchJsonRpcResponse, HttpJsonRpcRequest, HttpJsonRpcResponse, + Id, JsonRpcHttpLayer, JsonRpcRequest, JsonRpcResponse, + }, observability::ObservabilityLayer, Client, }; @@ -47,28 +51,112 @@ where { ServiceBuilder::new() // Print request, response and errors to the console - .layer( - ObservabilityLayer::new() - .on_request(|request: &HttpJsonRpcRequest| ic_cdk::println!("{request:?}")) - .on_response(|_, response: &HttpJsonRpcResponse| { - ic_cdk::println!("{response:?}"); - }) - .on_error(|_, error: &BoxError| { - ic_cdk::println!("Error {error:?}"); - }), - ) - // Deal with JSON-RPC over HTTP requests and responses - .layer(JsonRpcHttpLayer::::new()) + .layer(observability_layer()) + // Convert request and response to JSON-RPC over HTTP and validate response ID + .layer(JsonRpcHttpLayer::new()) // Use cycles from the canister to pay for HTTPs outcalls .cycles_accounting(ChargeMyself::default()) // The actual client .service(Client::new_with_box_error()) } +/// Make a batch JSON-RPC request to the Solana JSON-RPC API. +#[update] +pub async fn make_batch_json_rpc_request() -> SlotInfo { + // Send a `getSlot` JSON-RPC request that fetches the current height of the Solana blockchain + // together with a `getSlotLeader` that fetches the identity of the leader for that slot. + let requests = http::Request::post(solana_test_validator_base_url()) + .header("Content-Type", "application/json") + .body(vec![ + JsonRpcRequest::new("getSlot", json!([{"commitment": "finalized"}])).with_id(0_u64), + JsonRpcRequest::new("getSlotLeader", json!([{"commitment": "finalized"}])) + .with_id(1_u64), + ]) + .unwrap(); + + let response = batch_json_rpc_client() + .ready() + .await + .expect("Client should be ready") + .call(requests) + .await + .expect("Request should succeed"); + assert_eq!(response.status(), http::StatusCode::OK); + + let [get_slot_response, get_slot_leader_response]: [JsonRpcResponse; 2] = + response + .into_body() + .try_into() + .expect("Expected exactly 2 JSON-RPC responses"); + + assert_eq!(get_slot_response.id(), &Id::Number(0)); + let slot = get_slot_response + .into_result() + .expect("`getSlot` call should succeed") + .as_u64() + .expect("Invalid `getSlot` response"); + ic_cdk::println!("Slot: {:?}", slot); + + assert_eq!(get_slot_leader_response.id(), &Id::Number(1)); + let leader = get_slot_leader_response + .into_result() + .expect("`getSlotLeader` call should succeed") + .as_str() + .expect("Invalid `getSlotLeader` response") + .to_string(); + ic_cdk::println!("Slot leader: {:?}", leader); + + SlotInfo { slot, leader } +} + +fn batch_json_rpc_client() -> impl Service< + HttpBatchJsonRpcRequest, + Response = HttpBatchJsonRpcResponse, + Error = BoxError, +> +where + Params: Debug + Serialize, + Result: Debug + DeserializeOwned, +{ + ServiceBuilder::new() + // Print request, response and errors to the console + .layer(observability_layer()) + // Convert request and response batches to JSON-RPC over HTTP and validate response IDs + .layer(JsonRpcHttpLayer::new()) + // Use cycles from the canister to pay for HTTPs outcalls + .cycles_accounting(ChargeMyself::default()) + // The actual client + .service(Client::new_with_box_error()) +} + +fn observability_layer( +) -> ObservabilityLayer, ResponseObserver, ErrorObserver> { + ObservabilityLayer::new() + .on_request::>(|request: &Request| { + ic_cdk::println!("{request:?}"); + }) + .on_response::>(|_, response: &Response| { + ic_cdk::println!("{response:?}"); + }) + .on_error::(|_, error: &BoxError| { + ic_cdk::println!("Error {error:?}"); + }) +} + +type RequestObserver = fn(&Request); +type ResponseObserver = fn((), &Response); +type ErrorObserver = fn((), &BoxError); + fn solana_test_validator_base_url() -> String { option_env!("SOLANA_TEST_VALIDATOR_URL") - .unwrap_or_else(|| "https://api.devnet.solana.com") + .unwrap_or_else(|| "https://api.mainnet-beta.solana.com") .to_string() } fn main() {} + +#[derive(CandidType, Deserialize)] +pub struct SlotInfo { + slot: u64, + leader: String, +} diff --git a/examples/json_rpc_canister/tests/tests.rs b/examples/json_rpc_canister/tests/tests.rs index 8adcca4..d05276a 100644 --- a/examples/json_rpc_canister/tests/tests.rs +++ b/examples/json_rpc_canister/tests/tests.rs @@ -1,13 +1,33 @@ +use candid::{CandidType, Deserialize}; use test_fixtures::Setup; #[tokio::test] async fn should_make_json_rpc_request() { let setup = Setup::new("json_rpc_canister").await; - let json_rpc_request_result = setup + let result = setup .canister() .update_call::<_, u64>("make_json_rpc_request", ()) .await; - assert!(json_rpc_request_result > 0); + assert!(result > 0); +} + +#[tokio::test] +async fn should_make_batch_json_rpc_request() { + let setup = Setup::new("json_rpc_canister").await; + + let result = setup + .canister() + .update_call::<_, SlotInfo>("make_batch_json_rpc_request", ()) + .await; + + assert!(result.slot > 0); + assert_eq!(result.leader.len(), 44); +} + +#[derive(CandidType, Deserialize)] +struct SlotInfo { + slot: u64, + leader: String, }