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..d996b91 100644 --- a/canhttp/Cargo.toml +++ b/canhttp/Cargo.toml @@ -14,12 +14,13 @@ 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 } 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..4d3efdb 100644 --- a/canhttp/src/http/json/response.rs +++ b/canhttp/src/http/json/response.rs @@ -1,11 +1,14 @@ -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::http::json::BatchJsonRpcRequest; +use crate::{ + convert::{Convert, CreateResponseFilter, Filter}, + http::{ + json::{HttpBatchJsonRpcRequest, HttpJsonRpcRequest, Id, JsonRpcRequest, Version}, + HttpResponse, + }, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; -use std::marker::PhantomData; +use std::{collections::BTreeSet, fmt::Debug, marker::PhantomData}; use thiserror::Error; /// Convert responses of type [HttpResponse] into [`http::Response`], where `T` is `Deserialize` @@ -78,12 +81,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, @@ -251,7 +264,7 @@ impl JsonRpcError { pub enum ConsistentResponseIdFilterError { /// ID of the response does not match that of the request. #[error( - "Unexpected identifier: expected response ID to be {request_id}, but got {response_id}" + "Unexpected identifier: expected response ID to be {request_id:?}, but got {response_id:?}" )] InconsistentId { /// Response status code. @@ -261,14 +274,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: BTreeSet, + /// IDs from the response. + response_ids: BTreeSet, + }, } /// 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 +302,7 @@ impl CreateJsonRpcIdFilter { } } -impl Clone for CreateJsonRpcIdFilter { +impl Clone for CreateJsonRpcIdFilter { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -285,45 +310,65 @@ 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; + type Filter = ConsistentJsonRpcIdFilter, JsonRpcResponse, Id>; type Error = ConsistentResponseIdFilterError; - fn create_filter(&self, request: &HttpJsonRpcRequest) -> ConsistentJsonRpcIdFilter { - ConsistentJsonRpcIdFilter::new(request.body().id().clone()) + fn create_filter(&self, request: &HttpJsonRpcRequest) -> Self::Filter { + let request_id = expected_response_id(request.body()); + ConsistentJsonRpcIdFilter::new(request_id) + } +} + +impl CreateResponseFilter, HttpBatchJsonRpcResponse> + for CreateJsonRpcIdFilter, BatchJsonRpcResponse> +where + BatchJsonRpcRequest: Serialize, + BatchJsonRpcResponse: DeserializeOwned, +{ + type Filter = + ConsistentJsonRpcIdFilter, BatchJsonRpcResponse, BTreeSet>; + type Error = ConsistentResponseIdFilterError; + + fn create_filter(&self, requests: &HttpBatchJsonRpcRequest) -> Self::Filter { + let request_id = requests + .body() + .iter() + .map(expected_response_id) + .collect::>(); + ConsistentJsonRpcIdFilter::new(request_id) } } /// Ensure that the ID of the response is consistent with the one from the request /// that is stored internally. -pub struct ConsistentJsonRpcIdFilter { +pub struct ConsistentJsonRpcIdFilter { request_id: Id, - _marker: PhantomData, + _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 ID(s) match(es) the given request + /// ID(s). /// /// # 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_id: Id) -> Self { Self { request_id, _marker: PhantomData, @@ -331,32 +376,90 @@ impl ConsistentJsonRpcIdFilter { } } -impl Filter> for ConsistentJsonRpcIdFilter { +impl Filter> + for ConsistentJsonRpcIdFilter, JsonRpcResponse, Id> +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); + let response_id = response.body().id(); + if &self.request_id == response_id || should_have_null_id(response.body()) { + Ok(response) + } else { + Err(ConsistentResponseIdFilterError::InconsistentId { + status: response.status().into(), + request_id: self.request_id.clone(), + response_id: response_id.clone(), + }) } + } +} + +impl Filter> + for ConsistentJsonRpcIdFilter, BatchJsonRpcResponse, BTreeSet> +where + BatchJsonRpcRequest: Serialize, + BatchJsonRpcResponse: DeserializeOwned, +{ + type Error = ConsistentResponseIdFilterError; - 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); + fn filter( + &mut self, + responses: HttpBatchJsonRpcResponse, + ) -> Result, Self::Error> { + let request_ids = &self.request_id; + + let expected_missing_id_count = responses + .body() + .iter() + .filter(|response| should_have_null_id(response)) + .count(); + + let response_ids = responses + .body() + .iter() + .map(|response| response.id()) + .collect::>(); + + let missing_id_count = request_ids + .iter() + .filter(|id| !response_ids.contains(id)) + .count(); + + let unexpected_id_count = response_ids + .iter() + .filter(|id| !request_ids.contains(id)) + .count(); + + if (unexpected_id_count == 0) && (missing_id_count <= expected_missing_id_count) { + Ok(responses) + } else { + Err(ConsistentResponseIdFilterError::InconsistentBatchIds { + status: responses.status().into(), + request_ids: request_ids.clone(), + response_ids: response_ids.into_iter().cloned().collect(), + }) } + } +} + +// 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()) +} - Err(ConsistentResponseIdFilterError::InconsistentId { - status: response.status().as_u16(), - request_id: request_id.clone(), - response_id: response_id.clone(), - }) +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() } } diff --git a/canhttp/src/http/json/tests.rs b/canhttp/src/http/json/tests.rs index 1e03b95..80d7381 100644 --- a/canhttp/src/http/json/tests.rs +++ b/canhttp/src/http/json/tests.rs @@ -1,16 +1,24 @@ -use crate::http::json::{JsonConversionLayer, JsonRequestConverter, JsonResponseConverter}; -use crate::http::{HttpRequest, HttpResponse}; -use crate::ConvertServiceBuilder; +use crate::{ + http::{ + json::{ + ConstantSizeId, CreateJsonRpcIdFilter, HttpJsonRpcRequest, Id, JsonConversionLayer, + JsonRequestConverter, JsonResponseConverter, JsonRpcError, JsonRpcRequest, + JsonRpcResponse, Version, + }, + HttpRequest, HttpResponse, + }, + ConvertServiceBuilder, +}; +use assert_matches::assert_matches; use http::HeaderValue; +use proptest::{prelude::any, prop_assert_eq, proptest}; +use serde::de::DeserializeOwned; use serde_json::json; +use std::fmt::Debug; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; 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 +114,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() { @@ -146,6 +152,8 @@ mod constant_size_id { } } +// TODO: Add tests for batch JSON-RPC + #[tokio::test] async fn should_convert_json_request() { let url = "https://internetcomputer.org/"; @@ -164,6 +172,8 @@ async fn should_convert_json_request() { ); } +// TODO: Add tests for batch JSON-RPC + #[tokio::test] async fn should_add_content_type_header_if_missing() { let url = "https://internetcomputer.org/"; @@ -215,6 +225,8 @@ async fn should_add_content_type_header_if_missing() { } } +// TODO: Add tests for batch JSON-RPC + #[tokio::test] async fn should_convert_json_response() { let mut service = ServiceBuilder::new() @@ -229,6 +241,8 @@ async fn should_convert_json_response() { assert_eq!(converted_response.into_body(), expected_response); } +// TODO: Add tests for batch JSON-RPC + #[tokio::test] async fn should_convert_both_request_and_response() { let mut service = ServiceBuilder::new() @@ -251,13 +265,9 @@ 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::*; + + // TODO: Add tests for batch JSON-RPC #[tokio::test] async fn should_check_json_rpc_id_is_consistent() { @@ -305,7 +315,6 @@ mod filter_json_rpc_id { Err("expected response ID".to_string()), ) .await; - check( Id::from(42_u64), JsonRpcResponse::from_error( diff --git a/examples/json_rpc_canister/src/main.rs b/examples/json_rpc_canister/src/main.rs index c86c80b..a74c97a 100644 --- a/examples/json_rpc_canister/src/main.rs +++ b/examples/json_rpc_canister/src/main.rs @@ -2,7 +2,10 @@ use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, - http::json::{HttpJsonRpcRequest, HttpJsonRpcResponse, Id, JsonRpcHttpLayer, JsonRpcRequest}, + http::json::{ + HttpBatchJsonRpcRequest, HttpBatchJsonRpcResponse, HttpJsonRpcRequest, HttpJsonRpcResponse, + Id, JsonRpcHttpLayer, JsonRpcRequest, + }, observability::ObservabilityLayer, Client, }; @@ -47,27 +50,91 @@ 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() -> Vec { + // Send [`getSlot`](https://solana.com/docs/rpc/http/getslot) JSON-RPC requests that fetch + // the current height of the Solana blockchain with different commitment requirements. + 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("getSlot", json!([{"commitment": "confirmed"}])).with_id(1_u64), + JsonRpcRequest::new("getSlot", json!([{"commitment": "processed"}])).with_id(2_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); + + response + .into_body() + .into_iter() + .zip(0_u64..) + .map(|(response, expected_id)| { + let (id, result) = response.into_parts(); + assert_eq!(id, expected_id.into()); + result.expect("JSON-RPC API call should succeed") + }) + .collect() +} + +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() } diff --git a/examples/json_rpc_canister/tests/tests.rs b/examples/json_rpc_canister/tests/tests.rs index 8adcca4..f60c8e2 100644 --- a/examples/json_rpc_canister/tests/tests.rs +++ b/examples/json_rpc_canister/tests/tests.rs @@ -4,10 +4,24 @@ use test_fixtures::Setup; 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::<_, Vec>("make_batch_json_rpc_request", ()) + .await; + + for value in result { + assert!(value > 0); + } }