From 0410582adae066ded4a38dea40bd95b3edd67332 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 18 Dec 2025 14:00:51 +0100 Subject: [PATCH 1/6] DEFI-2565: Add support for batched JSON-RPC requests --- Cargo.lock | 74 ++++++-- Cargo.toml | 1 + canhttp/Cargo.toml | 3 +- canhttp/src/http/json/id.rs | 10 +- canhttp/src/http/json/mod.rs | 41 +++-- canhttp/src/http/json/request.rs | 27 ++- canhttp/src/http/json/response.rs | 212 +++++++++++++++++----- canhttp/src/http/json/tests.rs | 39 ++-- examples/json_rpc_canister/src/main.rs | 99 +++++++--- examples/json_rpc_canister/tests/tests.rs | 18 +- 10 files changed, 378 insertions(+), 146 deletions(-) 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..247818a 100644 --- a/canhttp/src/http/json/mod.rs +++ b/canhttp/src/http/json/mod.rs @@ -61,12 +61,13 @@ 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; @@ -132,8 +133,12 @@ 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 @@ -142,11 +147,11 @@ where /// [`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 +160,7 @@ impl JsonRpcHttpLayer { } } -impl Clone for JsonRpcHttpLayer { +impl Clone for JsonRpcHttpLayer { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -163,32 +168,32 @@ 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, { type Service = FilterResponse< ConvertResponse< ConvertRequest< ConvertResponse, HttpResponseConverter>, - JsonRequestConverter>, + JsonRequestConverter, >, - JsonResponseConverter>, + JsonResponseConverter, >, - CreateJsonRpcIdFilter, + CreateJsonRpcIdFilter, http::Response>, >; 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..38a1b9a 100644 --- a/canhttp/src/http/json/response.rs +++ b/canhttp/src/http/json/response.rs @@ -1,11 +1,15 @@ -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::{HttpBatchJsonRpcRequest, JsonRpcRequest}; +use crate::{ + convert::{Convert, CreateResponseFilter, Filter}, + http::{ + json::{HttpJsonRpcRequest, Id, Version}, + HttpResponse, + }, +}; +use derive_more::{From, TryUnwrap}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; -use std::marker::PhantomData; +use std::{collections::BTreeSet, marker::PhantomData}; use thiserror::Error; /// Convert responses of type [HttpResponse] into [`http::Response`], where `T` is `Deserialize` @@ -78,12 +82,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, @@ -261,14 +275,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 response IDs to be {request_ids:?}, but got {response_ids:?}" + )] + InconsistentIds { + /// 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 +303,7 @@ impl CreateJsonRpcIdFilter { } } -impl Clone for CreateJsonRpcIdFilter { +impl Clone for CreateJsonRpcIdFilter { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -285,78 +311,166 @@ impl Clone for CreateJsonRpcIdFilter { } } -impl Default for CreateJsonRpcIdFilter { +impl Default for CreateJsonRpcIdFilter { fn default() -> Self { Self::new() } } impl CreateResponseFilter, HttpJsonRpcResponse> - for CreateJsonRpcIdFilter + for CreateJsonRpcIdFilter, HttpJsonRpcResponse> { - type Filter = ConsistentJsonRpcIdFilter; + type Filter = ConsistentJsonRpcIdFilter>; type Error = ConsistentResponseIdFilterError; - fn create_filter(&self, request: &HttpJsonRpcRequest) -> ConsistentJsonRpcIdFilter { - ConsistentJsonRpcIdFilter::new(request.body().id().clone()) + fn create_filter( + &self, + request: &HttpJsonRpcRequest, + ) -> ConsistentJsonRpcIdFilter> { + ConsistentJsonRpcIdFilter::>::new(expected_response_id( + request.body(), + )) + } +} + +impl CreateResponseFilter, HttpBatchJsonRpcResponse> + for CreateJsonRpcIdFilter, HttpBatchJsonRpcResponse> +{ + type Filter = ConsistentJsonRpcIdFilter>; + type Error = ConsistentResponseIdFilterError; + + fn create_filter( + &self, + request: &HttpBatchJsonRpcRequest, + ) -> ConsistentJsonRpcIdFilter> { + let request_ids = request + .body() + .iter() + .map(expected_response_id) + .collect::>(); + 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: ExpectedJsonRpcResponseIds, + _marker: PhantomData, } impl ConsistentJsonRpcIdFilter { - /// Creates a new JSON-RPC filter to ensure that the ID of the response matches the one given in parameter. + /// Creates a new JSON-RPC filter to ensure that the response ID(s) match(es) the ID(s) of the + /// given request. /// /// # 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: impl Into) -> Self { Self { - request_id, + request_ids: request_ids.into(), _marker: PhantomData, } } } -impl Filter> for ConsistentJsonRpcIdFilter { +#[derive(From, TryUnwrap)] +#[try_unwrap(ref)] +enum ExpectedJsonRpcResponseIds { + Single(Id), + Batch(BTreeSet), +} + +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() + } +} + +impl Filter> + for ConsistentJsonRpcIdFilter> +{ 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); + response: HttpBatchJsonRpcResponse, + ) -> Result, Self::Error> { + let request_ids = match &self.request_ids { + ExpectedJsonRpcResponseIds::Single(_) => { + panic!("ERROR: Filter was incorrectly created for a single JSON-RPC response") + } + ExpectedJsonRpcResponseIds::Batch(request_ids) => request_ids, + }; + + let expected_missing_id_count = response + .body() + .iter() + .filter(|response| should_have_null_id(response)) + .count(); + + let response_ids = response + .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(response) + } else { + Err(ConsistentResponseIdFilterError::InconsistentIds { + status: response.status().as_u16(), + request_ids: request_ids.to_owned(), + response_ids: response_ids.into_iter().map(|id| id.to_owned()).collect(), + }) } + } +} - 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); - } +impl Filter> for ConsistentJsonRpcIdFilter> { + 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: HttpJsonRpcResponse, + ) -> Result, Self::Error> { + let request_id = match &self.request_ids { + ExpectedJsonRpcResponseIds::Single(request_id) => request_id, + ExpectedJsonRpcResponseIds::Batch(_) => { + panic!("ERROR: Filter was incorrectly created for a batch JSON-RPC response") + } + }; + + if (request_id == response.body().id()) || should_have_null_id(response.body()) { + Ok(response) + } else { + Err(ConsistentResponseIdFilterError::InconsistentId { + status: response.status().as_u16(), + request_id: request_id.clone(), + response_id: response.body().id().clone(), + }) + } } } + +// 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()) +} diff --git a/canhttp/src/http/json/tests.rs b/canhttp/src/http/json/tests.rs index 1e03b95..b448c1e 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() { @@ -251,13 +257,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 +307,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..e3ca07a 100644 --- a/examples/json_rpc_canister/src/main.rs +++ b/examples/json_rpc_canister/src/main.rs @@ -1,13 +1,11 @@ //! Example of a canister using `canhttp` to issue JSON-RPC HTTP requests. - use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, - http::json::{HttpJsonRpcRequest, HttpJsonRpcResponse, Id, JsonRpcHttpLayer, JsonRpcRequest}, + http::json::{Id, JsonRpcHttpLayer, JsonRpcRequest}, observability::ObservabilityLayer, Client, }; use ic_cdk::update; -use serde::{de::DeserializeOwned, Serialize}; use serde_json::json; use std::fmt::Debug; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; @@ -24,7 +22,17 @@ pub async fn make_json_rpc_request() -> u64 { .body(JsonRpcRequest::new("getSlot", json!([{"commitment": "finalized"}])).with_id(ID)) .unwrap(); - let response = json_rpc_client() + // A client with layers to: + // * Print request, response and errors to the console + // * Handle JSON-RPC over HTTP requests and responses + // * Use cycles from the canister to pay for HTTPs outcalls + let mut client = ServiceBuilder::new() + .layer(observability_layer()) + .layer(JsonRpcHttpLayer::new()) + .cycles_accounting(ChargeMyself::default()) + .service(Client::new_with_box_error()); + + let response = client .ready() .await .expect("Client should be ready") @@ -39,35 +47,70 @@ pub async fn make_json_rpc_request() -> u64 { result.expect("JSON-RPC API call should succeed") } -fn json_rpc_client( -) -> impl Service, Response = HttpJsonRpcResponse, Error = BoxError> -where - Params: Debug + Serialize, - Result: Debug + DeserializeOwned, -{ - 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()) - // Use cycles from the canister to pay for HTTPs outcalls +/// 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(); + + // A client with layers to: + // * Print request, response and errors to the console + // * Handle JSON-RPC over HTTP requests and responses + // * Use cycles from the canister to pay for HTTPs outcalls + let mut client = ServiceBuilder::new() + .layer(observability_layer()) + .layer(JsonRpcHttpLayer::new()) .cycles_accounting(ChargeMyself::default()) - // The actual client - .service(Client::new_with_box_error()) + .service(Client::new_with_box_error()); + + let response = 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() +} + +#[allow(clippy::type_complexity)] +fn observability_layer() -> ObservabilityLayer< + impl Fn(&Request) + Clone, + impl Fn((), &Response) + Clone, + impl Fn((), &BoxError) + Clone, +> { + 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:?}"); + }) } 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); + } } From bcc612df3b7698da6a82075c59a51bea932755a6 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 22 Dec 2025 15:18:25 +0100 Subject: [PATCH 2/6] Add `JsonRpcPayload` trait --- canhttp/src/http/json/mod.rs | 124 +++++++++++++++++- canhttp/src/http/json/response.rs | 171 ++++++------------------- examples/json_rpc_canister/src/main.rs | 65 ++++------ 3 files changed, 187 insertions(+), 173 deletions(-) diff --git a/canhttp/src/http/json/mod.rs b/canhttp/src/http/json/mod.rs index 247818a..2d0db22 100644 --- a/canhttp/src/http/json/mod.rs +++ b/canhttp/src/http/json/mod.rs @@ -51,7 +51,6 @@ //! ``` //! //! [`Service`]: tower::Service - use crate::{ convert::{ ConvertRequest, ConvertRequestLayer, ConvertResponse, ConvertResponseLayer, @@ -70,6 +69,8 @@ pub use response::{ JsonResponseConversionError, JsonResponseConverter, JsonRpcError, JsonRpcResponse, }; use serde::{de::DeserializeOwned, Serialize}; +use std::collections::BTreeSet; +use std::fmt::Debug; use std::marker::PhantomData; use tower_layer::{Layer, Stack}; pub use version::Version; @@ -176,8 +177,9 @@ impl Default for JsonRpcHttpLayer { impl Layer for JsonRpcHttpLayer where - Request: Serialize, - Response: DeserializeOwned, + (): JsonRpcPayload, + Request: Debug + Serialize, + Response: Debug + DeserializeOwned, { type Service = FilterResponse< ConvertResponse< @@ -187,7 +189,7 @@ where >, JsonResponseConverter, >, - CreateJsonRpcIdFilter, http::Response>, + CreateJsonRpcIdFilter, >; fn layer(&self, inner: S) -> Self::Service { @@ -203,3 +205,117 @@ where fn stack(l1: L1, l2: L2, l3: L3) -> Stack> { Stack::new(l1, Stack::new(l2, l3)) } + +type JsonRpcPayloadId = <() as JsonRpcPayload>::Id; + +/// TODO +pub trait JsonRpcPayload { + /// TODO + type Id: Debug; + + /// TODO + fn expected_response_id(request: &http::Request) -> Self::Id; + + /// TODO + fn has_consistent_response_id( + request_id: &Self::Id, + response: &http::Response, + ) -> Result<(), ConsistentResponseIdFilterError>; +} + +impl JsonRpcPayload, JsonRpcResponse> for () +where + Params: Debug + Serialize, + Result: Debug + DeserializeOwned, +{ + type Id = Id; + + fn expected_response_id(request: &HttpJsonRpcRequest) -> Self::Id { + expected_response_id(request.body()) + } + + fn has_consistent_response_id( + request_id: &Id, + response: &HttpJsonRpcResponse, + ) -> std::result::Result<(), ConsistentResponseIdFilterError> { + let response_id = response.body().id(); + if request_id == response_id || should_have_null_id(response.body()) { + Ok(()) + } else { + Err(ConsistentResponseIdFilterError::InconsistentId { + status: response.status().into(), + request_id: request_id.clone(), + response_id: response_id.clone(), + }) + } + } +} + +impl JsonRpcPayload, BatchJsonRpcResponse> + for () +where + Params: Debug + Serialize, + Result: Debug + DeserializeOwned, +{ + type Id = BTreeSet; + + fn expected_response_id(requests: &HttpBatchJsonRpcRequest) -> Self::Id { + requests + .body() + .iter() + .map(expected_response_id) + .collect::>() + } + + fn has_consistent_response_id( + request_ids: &BTreeSet, + responses: &HttpBatchJsonRpcResponse, + ) -> std::result::Result<(), ConsistentResponseIdFilterError> { + 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(()) + } 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()) +} + +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/response.rs b/canhttp/src/http/json/response.rs index 38a1b9a..165cd57 100644 --- a/canhttp/src/http/json/response.rs +++ b/canhttp/src/http/json/response.rs @@ -1,15 +1,13 @@ -use crate::http::json::{HttpBatchJsonRpcRequest, JsonRpcRequest}; use crate::{ convert::{Convert, CreateResponseFilter, Filter}, http::{ - json::{HttpJsonRpcRequest, Id, Version}, + json::{Id, JsonRpcPayload, JsonRpcPayloadId, Version}, HttpResponse, }, }; -use derive_more::{From, TryUnwrap}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; -use std::{collections::BTreeSet, 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` @@ -265,7 +263,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. @@ -277,9 +275,9 @@ pub enum ConsistentResponseIdFilterError { }, /// IDs in the response either contain unexpected IDs or are missing some request IDs #[error( - "Inconsistent identifiers: expected response IDs to be {request_ids:?}, but got {response_ids:?}" + "Inconsistent identifiers: expected batch response IDs to be {request_ids:?}, but got {response_ids:?}" )] - InconsistentIds { + InconsistentBatchIds { /// Response status code. status: u16, /// IDs from the request. @@ -317,49 +315,39 @@ impl Default for CreateJsonRpcIdFilter { } } -impl CreateResponseFilter, HttpJsonRpcResponse> - for CreateJsonRpcIdFilter, HttpJsonRpcResponse> -{ - type Filter = ConsistentJsonRpcIdFilter>; - type Error = ConsistentResponseIdFilterError; - - fn create_filter( - &self, - request: &HttpJsonRpcRequest, - ) -> ConsistentJsonRpcIdFilter> { - ConsistentJsonRpcIdFilter::>::new(expected_response_id( - request.body(), - )) - } -} - -impl CreateResponseFilter, HttpBatchJsonRpcResponse> - for CreateJsonRpcIdFilter, HttpBatchJsonRpcResponse> +impl CreateResponseFilter, http::Response> + for CreateJsonRpcIdFilter +where + (): JsonRpcPayload, + Request: Debug + Serialize, + Response: Debug + DeserializeOwned, { - type Filter = ConsistentJsonRpcIdFilter>; + type Filter = ConsistentJsonRpcIdFilter; type Error = ConsistentResponseIdFilterError; - fn create_filter( - &self, - request: &HttpBatchJsonRpcRequest, - ) -> ConsistentJsonRpcIdFilter> { - let request_ids = request - .body() - .iter() - .map(expected_response_id) - .collect::>(); - ConsistentJsonRpcIdFilter::>::new(request_ids) + fn create_filter(&self, request: &http::Request) -> Self::Filter { + ConsistentJsonRpcIdFilter::new(request) } } /// Ensure that the ID of the response is consistent with the one from the request /// that is stored internally. -pub struct ConsistentJsonRpcIdFilter { - request_ids: ExpectedJsonRpcResponseIds, - _marker: PhantomData, +pub struct ConsistentJsonRpcIdFilter +where + (): JsonRpcPayload, + Request: Debug + Serialize, + Response: Debug + DeserializeOwned, +{ + request_id: JsonRpcPayloadId, + _marker: PhantomData<(Request, Response)>, } -impl ConsistentJsonRpcIdFilter { +impl ConsistentJsonRpcIdFilter +where + (): JsonRpcPayload, + Request: Debug + Serialize, + Response: Debug + DeserializeOwned, +{ /// Creates a new JSON-RPC filter to ensure that the response ID(s) match(es) the ID(s) of the /// given request. /// @@ -369,108 +357,27 @@ impl ConsistentJsonRpcIdFilter { /// 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)). - fn new(request_ids: impl Into) -> Self { + fn new(request: &http::Request) -> Self { Self { - request_ids: request_ids.into(), + request_id: <()>::expected_response_id(request), _marker: PhantomData, } } } -#[derive(From, TryUnwrap)] -#[try_unwrap(ref)] -enum ExpectedJsonRpcResponseIds { - Single(Id), - Batch(BTreeSet), -} - -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() - } -} - -impl Filter> - for ConsistentJsonRpcIdFilter> +impl Filter> + for ConsistentJsonRpcIdFilter +where + (): JsonRpcPayload, + Request: Debug + Serialize, + Response: Debug + DeserializeOwned, { type Error = ConsistentResponseIdFilterError; fn filter( &mut self, - response: HttpBatchJsonRpcResponse, - ) -> Result, Self::Error> { - let request_ids = match &self.request_ids { - ExpectedJsonRpcResponseIds::Single(_) => { - panic!("ERROR: Filter was incorrectly created for a single JSON-RPC response") - } - ExpectedJsonRpcResponseIds::Batch(request_ids) => request_ids, - }; - - let expected_missing_id_count = response - .body() - .iter() - .filter(|response| should_have_null_id(response)) - .count(); - - let response_ids = response - .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(response) - } else { - Err(ConsistentResponseIdFilterError::InconsistentIds { - status: response.status().as_u16(), - request_ids: request_ids.to_owned(), - response_ids: response_ids.into_iter().map(|id| id.to_owned()).collect(), - }) - } + response: http::Response, + ) -> Result, Self::Error> { + <()>::has_consistent_response_id(&self.request_id, &response).map(|_| response) } } - -impl Filter> for ConsistentJsonRpcIdFilter> { - type Error = ConsistentResponseIdFilterError; - - fn filter( - &mut self, - response: HttpJsonRpcResponse, - ) -> Result, Self::Error> { - let request_id = match &self.request_ids { - ExpectedJsonRpcResponseIds::Single(request_id) => request_id, - ExpectedJsonRpcResponseIds::Batch(_) => { - panic!("ERROR: Filter was incorrectly created for a batch JSON-RPC response") - } - }; - - if (request_id == response.body().id()) || should_have_null_id(response.body()) { - Ok(response) - } else { - Err(ConsistentResponseIdFilterError::InconsistentId { - status: response.status().as_u16(), - request_id: request_id.clone(), - response_id: response.body().id().clone(), - }) - } - } -} - -// 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()) -} diff --git a/examples/json_rpc_canister/src/main.rs b/examples/json_rpc_canister/src/main.rs index e3ca07a..4eb9077 100644 --- a/examples/json_rpc_canister/src/main.rs +++ b/examples/json_rpc_canister/src/main.rs @@ -1,11 +1,15 @@ //! Example of a canister using `canhttp` to issue JSON-RPC HTTP requests. use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, - http::json::{Id, JsonRpcHttpLayer, JsonRpcRequest}, + http::json::{ + HttpBatchJsonRpcResponse, HttpJsonRpcResponse, Id, JsonRpcHttpLayer, JsonRpcPayload, + JsonRpcRequest, + }, observability::ObservabilityLayer, Client, }; use ic_cdk::update; +use serde::{de::DeserializeOwned, Serialize}; use serde_json::json; use std::fmt::Debug; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; @@ -22,17 +26,7 @@ pub async fn make_json_rpc_request() -> u64 { .body(JsonRpcRequest::new("getSlot", json!([{"commitment": "finalized"}])).with_id(ID)) .unwrap(); - // A client with layers to: - // * Print request, response and errors to the console - // * Handle JSON-RPC over HTTP requests and responses - // * Use cycles from the canister to pay for HTTPs outcalls - let mut client = ServiceBuilder::new() - .layer(observability_layer()) - .layer(JsonRpcHttpLayer::new()) - .cycles_accounting(ChargeMyself::default()) - .service(Client::new_with_box_error()); - - let response = client + let response: HttpJsonRpcResponse = client() .ready() .await .expect("Client should be ready") @@ -61,17 +55,7 @@ pub async fn make_batch_json_rpc_request() -> Vec { ]) .unwrap(); - // A client with layers to: - // * Print request, response and errors to the console - // * Handle JSON-RPC over HTTP requests and responses - // * Use cycles from the canister to pay for HTTPs outcalls - let mut client = ServiceBuilder::new() - .layer(observability_layer()) - .layer(JsonRpcHttpLayer::new()) - .cycles_accounting(ChargeMyself::default()) - .service(Client::new_with_box_error()); - - let response = client + let response: HttpBatchJsonRpcResponse = client() .ready() .await .expect("Client should be ready") @@ -92,20 +76,27 @@ pub async fn make_batch_json_rpc_request() -> Vec { .collect() } -#[allow(clippy::type_complexity)] -fn observability_layer() -> ObservabilityLayer< - impl Fn(&Request) + Clone, - impl Fn((), &Response) + Clone, - impl Fn((), &BoxError) + Clone, -> { - 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:?}"); - }) +fn client( +) -> impl Service, Response = http::Response, Error = BoxError> +where + (): JsonRpcPayload, + Request: Debug + Serialize, + Response: Debug + DeserializeOwned, +{ + ServiceBuilder::new() + .layer( + ObservabilityLayer::new() + .on_request(|request: &http::Request| ic_cdk::println!("{request:?}")) + .on_response(|_, response: &http::Response| { + ic_cdk::println!("{response:?}"); + }) + .on_error(|_, error: &BoxError| { + ic_cdk::println!("Error {error:?}"); + }), + ) + .layer(JsonRpcHttpLayer::new()) + .cycles_accounting(ChargeMyself::default()) + .service(Client::new_with_box_error()) } fn solana_test_validator_base_url() -> String { From 05829f53a9e6c44d0a6eb4cac95a42efcdec4350 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 22 Dec 2025 16:38:43 +0100 Subject: [PATCH 3/6] Refactor into `JsonRpcCall` trait --- canhttp/src/http/json/mod.rs | 66 ++++++++++++++++---------- canhttp/src/http/json/response.rs | 57 ++++++++++------------ canhttp/src/http/json/tests.rs | 18 ++++++- examples/json_rpc_canister/src/main.rs | 44 +++++++++-------- 4 files changed, 104 insertions(+), 81 deletions(-) diff --git a/canhttp/src/http/json/mod.rs b/canhttp/src/http/json/mod.rs index 2d0db22..83046aa 100644 --- a/canhttp/src/http/json/mod.rs +++ b/canhttp/src/http/json/mod.rs @@ -148,11 +148,11 @@ where /// [`Service`]: tower::Service /// [JSON-RPC 2.0 specification]: https://www.jsonrpc.org/specification #[derive(Debug)] -pub struct JsonRpcHttpLayer { - _marker: PhantomData<(Request, Response)>, +pub struct JsonRpcHttpLayer { + _marker: PhantomData, } -impl JsonRpcHttpLayer { +impl JsonRpcHttpLayer { /// Returns a new [`JsonRpcHttpLayer`]. pub fn new() -> Self { Self { @@ -161,7 +161,7 @@ impl JsonRpcHttpLayer { } } -impl Clone for JsonRpcHttpLayer { +impl Clone for JsonRpcHttpLayer { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -169,33 +169,31 @@ 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 - (): JsonRpcPayload, - Request: Debug + Serialize, - Response: Debug + DeserializeOwned, + Call: JsonRpcCall, { type Service = FilterResponse< ConvertResponse< ConvertRequest< ConvertResponse, HttpResponseConverter>, - JsonRequestConverter, + JsonRequestConverter, >, - JsonResponseConverter, + JsonResponseConverter, >, - CreateJsonRpcIdFilter, + CreateJsonRpcIdFilter, >; fn layer(&self, inner: S) -> Self::Service { stack( HttpConversionLayer, - JsonConversionLayer::::new(), + JsonConversionLayer::::new(), CreateResponseFilterLayer::new(CreateJsonRpcIdFilter::new()), ) .layer(inner) @@ -206,28 +204,43 @@ fn stack(l1: L1, l2: L2, l3: L3) -> Stack> { Stack::new(l1, Stack::new(l2, l3)) } -type JsonRpcPayloadId = <() as JsonRpcPayload>::Id; - -/// TODO -pub trait JsonRpcPayload { - /// TODO +/// Represents a JSON-RPC request/response pair and its ID semantics. +/// +/// Defines the request and response types, the ID type, and how to generate +/// and verify that a response matches a request. +pub trait JsonRpcCall { + /// The request type. + type Request: Debug + Serialize; + /// The response type. + type Response: Debug + DeserializeOwned; + /// The type used to identify requests and responses. type Id: Debug; - /// TODO - fn expected_response_id(request: &http::Request) -> Self::Id; + /// Returns the expected response ID for a given request. + /// + /// # Panics + /// + /// Panics if the request ID is [`Id::Null`], which indicates a notification + /// (a request for which no response is expected). + fn expected_response_id(request: &http::Request) -> Self::Id; - /// TODO + /// Checks that a response has a consistent ID for the given request ID. + /// + /// Returns `Ok(())` if the response ID is consistent, or + /// `ConsistentResponseIdFilterError` if it is not. fn has_consistent_response_id( request_id: &Self::Id, - response: &http::Response, + response: &http::Response, ) -> Result<(), ConsistentResponseIdFilterError>; } -impl JsonRpcPayload, JsonRpcResponse> for () +impl JsonRpcCall for (JsonRpcRequest, JsonRpcResponse) where Params: Debug + Serialize, Result: Debug + DeserializeOwned, { + type Request = JsonRpcRequest; + type Response = JsonRpcResponse; type Id = Id; fn expected_response_id(request: &HttpJsonRpcRequest) -> Self::Id { @@ -251,12 +264,13 @@ where } } -impl JsonRpcPayload, BatchJsonRpcResponse> - for () +impl JsonRpcCall for (BatchJsonRpcRequest, BatchJsonRpcResponse) where Params: Debug + Serialize, Result: Debug + DeserializeOwned, { + type Request = BatchJsonRpcRequest; + type Response = BatchJsonRpcResponse; type Id = BTreeSet; fn expected_response_id(requests: &HttpBatchJsonRpcRequest) -> Self::Id { @@ -268,7 +282,7 @@ where } fn has_consistent_response_id( - request_ids: &BTreeSet, + request_ids: &Self::Id, responses: &HttpBatchJsonRpcResponse, ) -> std::result::Result<(), ConsistentResponseIdFilterError> { let expected_missing_id_count = responses diff --git a/canhttp/src/http/json/response.rs b/canhttp/src/http/json/response.rs index 165cd57..c7da2fa 100644 --- a/canhttp/src/http/json/response.rs +++ b/canhttp/src/http/json/response.rs @@ -1,7 +1,7 @@ use crate::{ convert::{Convert, CreateResponseFilter, Filter}, http::{ - json::{Id, JsonRpcPayload, JsonRpcPayloadId, Version}, + json::{Id, JsonRpcCall, Version}, HttpResponse, }, }; @@ -288,11 +288,11 @@ pub enum ConsistentResponseIdFilterError { } /// Create [`ConsistentJsonRpcIdFilter`] for each request. -pub struct CreateJsonRpcIdFilter { - _marker: PhantomData<(Request, Response)>, +pub struct CreateJsonRpcIdFilter { + _marker: PhantomData, } -impl CreateJsonRpcIdFilter { +impl CreateJsonRpcIdFilter { /// Create a new instance of [`CreateJsonRpcIdFilter`] pub fn new() -> Self { Self { @@ -301,7 +301,7 @@ impl CreateJsonRpcIdFilter { } } -impl Clone for CreateJsonRpcIdFilter { +impl Clone for CreateJsonRpcIdFilter { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -309,44 +309,38 @@ impl Clone for CreateJsonRpcIdFilter { } } -impl Default for CreateJsonRpcIdFilter { +impl Default for CreateJsonRpcIdFilter { fn default() -> Self { Self::new() } } -impl CreateResponseFilter, http::Response> - for CreateJsonRpcIdFilter +impl CreateResponseFilter, http::Response> + for CreateJsonRpcIdFilter where - (): JsonRpcPayload, - Request: Debug + Serialize, - Response: Debug + DeserializeOwned, + Call: JsonRpcCall, { - type Filter = ConsistentJsonRpcIdFilter; + type Filter = ConsistentJsonRpcIdFilter; type Error = ConsistentResponseIdFilterError; - fn create_filter(&self, request: &http::Request) -> Self::Filter { + fn create_filter(&self, request: &http::Request) -> Self::Filter { ConsistentJsonRpcIdFilter::new(request) } } /// 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 where - (): JsonRpcPayload, - Request: Debug + Serialize, - Response: Debug + DeserializeOwned, + Call: JsonRpcCall, { - request_id: JsonRpcPayloadId, - _marker: PhantomData<(Request, Response)>, + request_id: Call::Id, + _marker: PhantomData, } -impl ConsistentJsonRpcIdFilter +impl ConsistentJsonRpcIdFilter where - (): JsonRpcPayload, - Request: Debug + Serialize, - Response: Debug + DeserializeOwned, + Call: JsonRpcCall, { /// Creates a new JSON-RPC filter to ensure that the response ID(s) match(es) the ID(s) of the /// given request. @@ -357,27 +351,24 @@ where /// 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)). - fn new(request: &http::Request) -> Self { + fn new(request: &http::Request) -> Self { Self { - request_id: <()>::expected_response_id(request), + request_id: Call::expected_response_id(request), _marker: PhantomData, } } } -impl Filter> - for ConsistentJsonRpcIdFilter +impl Filter> for ConsistentJsonRpcIdFilter where - (): JsonRpcPayload, - Request: Debug + Serialize, - Response: Debug + DeserializeOwned, + Call: JsonRpcCall, { type Error = ConsistentResponseIdFilterError; fn filter( &mut self, - response: http::Response, - ) -> Result, Self::Error> { - <()>::has_consistent_response_id(&self.request_id, &response).map(|_| response) + response: http::Response, + ) -> Result, Self::Error> { + Call::has_consistent_response_id(&self.request_id, &response).map(|_| response) } } diff --git a/canhttp/src/http/json/tests.rs b/canhttp/src/http/json/tests.rs index b448c1e..43c8348 100644 --- a/canhttp/src/http/json/tests.rs +++ b/canhttp/src/http/json/tests.rs @@ -152,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/"; @@ -170,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/"; @@ -221,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() @@ -235,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() @@ -275,7 +283,10 @@ mod filter_json_rpc_id { ) .unwrap(); let mut service = ServiceBuilder::new() - .filter_response(CreateJsonRpcIdFilter::new()) + .filter_response(CreateJsonRpcIdFilter::<( + JsonRpcRequest<_>, + JsonRpcResponse<_>, + )>::new()) .service_fn(|_request: HttpJsonRpcRequest| async { Ok::<_, BoxError>(http::Response::new(response.clone())) }); @@ -339,7 +350,10 @@ mod filter_json_rpc_id { #[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()) + .filter_response(CreateJsonRpcIdFilter::<( + JsonRpcRequest<_>, + JsonRpcResponse<_>, + )>::new()) .service_fn( |request: HttpJsonRpcRequest| async move { let id = request.body().id(); diff --git a/examples/json_rpc_canister/src/main.rs b/examples/json_rpc_canister/src/main.rs index 4eb9077..ebd9822 100644 --- a/examples/json_rpc_canister/src/main.rs +++ b/examples/json_rpc_canister/src/main.rs @@ -2,16 +2,14 @@ use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, http::json::{ - HttpBatchJsonRpcResponse, HttpJsonRpcResponse, Id, JsonRpcHttpLayer, JsonRpcPayload, - JsonRpcRequest, + BatchJsonRpcRequest, BatchJsonRpcResponse, Id, JsonRpcCall, JsonRpcHttpLayer, + JsonRpcRequest, JsonRpcResponse, }, observability::ObservabilityLayer, Client, }; use ic_cdk::update; -use serde::{de::DeserializeOwned, Serialize}; use serde_json::json; -use std::fmt::Debug; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; /// Make a JSON-RPC request to the Solana JSON-RPC API. @@ -26,7 +24,7 @@ pub async fn make_json_rpc_request() -> u64 { .body(JsonRpcRequest::new("getSlot", json!([{"commitment": "finalized"}])).with_id(ID)) .unwrap(); - let response: HttpJsonRpcResponse = client() + let response = client::<(JsonRpcRequest, JsonRpcResponse)>() .ready() .await .expect("Client should be ready") @@ -55,13 +53,16 @@ pub async fn make_batch_json_rpc_request() -> Vec { ]) .unwrap(); - let response: HttpBatchJsonRpcResponse = client() - .ready() - .await - .expect("Client should be ready") - .call(requests) - .await - .expect("Request should succeed"); + let response = client::<( + BatchJsonRpcRequest, + BatchJsonRpcResponse, + )>() + .ready() + .await + .expect("Client should be ready") + .call(requests) + .await + .expect("Request should succeed"); assert_eq!(response.status(), http::StatusCode::OK); response @@ -76,25 +77,28 @@ pub async fn make_batch_json_rpc_request() -> Vec { .collect() } -fn client( -) -> impl Service, Response = http::Response, Error = BoxError> +fn client() -> impl Service< + http::Request, + Response = http::Response, + Error = BoxError, +> where - (): JsonRpcPayload, - Request: Debug + Serialize, - Response: Debug + DeserializeOwned, + Call: JsonRpcCall, { ServiceBuilder::new() .layer( ObservabilityLayer::new() - .on_request(|request: &http::Request| ic_cdk::println!("{request:?}")) - .on_response(|_, response: &http::Response| { + .on_request(|request: &http::Request| { + ic_cdk::println!("{request:?}") + }) + .on_response(|_, response: &http::Response| { ic_cdk::println!("{response:?}"); }) .on_error(|_, error: &BoxError| { ic_cdk::println!("Error {error:?}"); }), ) - .layer(JsonRpcHttpLayer::new()) + .layer(JsonRpcHttpLayer::::new()) .cycles_accounting(ChargeMyself::default()) .service(Client::new_with_box_error()) } From dc4d0bb3cceb86435a70ecd364dcce3f8522ed25 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 22 Dec 2025 17:03:19 +0100 Subject: [PATCH 4/6] Revert to generic types for `Request` and `Response` --- canhttp/src/http/json/mod.rs | 50 ++++++++++-------------- canhttp/src/http/json/response.rs | 54 +++++++++++++++----------- canhttp/src/http/json/tests.rs | 10 +---- examples/json_rpc_canister/src/main.rs | 47 ++++++++++------------ 4 files changed, 74 insertions(+), 87 deletions(-) diff --git a/canhttp/src/http/json/mod.rs b/canhttp/src/http/json/mod.rs index 83046aa..6cb22e9 100644 --- a/canhttp/src/http/json/mod.rs +++ b/canhttp/src/http/json/mod.rs @@ -148,11 +148,11 @@ where /// [`Service`]: tower::Service /// [JSON-RPC 2.0 specification]: https://www.jsonrpc.org/specification #[derive(Debug)] -pub struct JsonRpcHttpLayer { - _marker: PhantomData, +pub struct JsonRpcHttpLayer { + _marker: PhantomData<(Request, Response)>, } -impl JsonRpcHttpLayer { +impl JsonRpcHttpLayer { /// Returns a new [`JsonRpcHttpLayer`]. pub fn new() -> Self { Self { @@ -161,7 +161,7 @@ impl JsonRpcHttpLayer { } } -impl Clone for JsonRpcHttpLayer { +impl Clone for JsonRpcHttpLayer { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -169,31 +169,33 @@ 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 - Call: JsonRpcCall, + (Request, Response): JsonRpcCall, + Request: Serialize, + Response: DeserializeOwned, { type Service = FilterResponse< ConvertResponse< ConvertRequest< ConvertResponse, HttpResponseConverter>, - JsonRequestConverter, + JsonRequestConverter, >, - JsonResponseConverter, + JsonResponseConverter, >, - CreateJsonRpcIdFilter, + CreateJsonRpcIdFilter, >; fn layer(&self, inner: S) -> Self::Service { stack( HttpConversionLayer, - JsonConversionLayer::::new(), + JsonConversionLayer::::new(), CreateResponseFilterLayer::new(CreateJsonRpcIdFilter::new()), ) .layer(inner) @@ -208,11 +210,7 @@ fn stack(l1: L1, l2: L2, l3: L3) -> Stack> { /// /// Defines the request and response types, the ID type, and how to generate /// and verify that a response matches a request. -pub trait JsonRpcCall { - /// The request type. - type Request: Debug + Serialize; - /// The response type. - type Response: Debug + DeserializeOwned; +pub trait JsonRpcCall { /// The type used to identify requests and responses. type Id: Debug; @@ -222,7 +220,7 @@ pub trait JsonRpcCall { /// /// Panics if the request ID is [`Id::Null`], which indicates a notification /// (a request for which no response is expected). - fn expected_response_id(request: &http::Request) -> Self::Id; + fn expected_response_id(request: &http::Request) -> Self::Id; /// Checks that a response has a consistent ID for the given request ID. /// @@ -230,17 +228,13 @@ pub trait JsonRpcCall { /// `ConsistentResponseIdFilterError` if it is not. fn has_consistent_response_id( request_id: &Self::Id, - response: &http::Response, + response: &http::Response, ) -> Result<(), ConsistentResponseIdFilterError>; } -impl JsonRpcCall for (JsonRpcRequest, JsonRpcResponse) -where - Params: Debug + Serialize, - Result: Debug + DeserializeOwned, +impl JsonRpcCall, JsonRpcResponse> + for (JsonRpcRequest, JsonRpcResponse) { - type Request = JsonRpcRequest; - type Response = JsonRpcResponse; type Id = Id; fn expected_response_id(request: &HttpJsonRpcRequest) -> Self::Id { @@ -264,13 +258,9 @@ where } } -impl JsonRpcCall for (BatchJsonRpcRequest, BatchJsonRpcResponse) -where - Params: Debug + Serialize, - Result: Debug + DeserializeOwned, +impl JsonRpcCall, BatchJsonRpcResponse> + for (BatchJsonRpcRequest, BatchJsonRpcResponse) { - type Request = BatchJsonRpcRequest; - type Response = BatchJsonRpcResponse; type Id = BTreeSet; fn expected_response_id(requests: &HttpBatchJsonRpcRequest) -> Self::Id { diff --git a/canhttp/src/http/json/response.rs b/canhttp/src/http/json/response.rs index c7da2fa..f6ee471 100644 --- a/canhttp/src/http/json/response.rs +++ b/canhttp/src/http/json/response.rs @@ -288,11 +288,11 @@ pub enum ConsistentResponseIdFilterError { } /// Create [`ConsistentJsonRpcIdFilter`] for each request. -pub struct CreateJsonRpcIdFilter { - _marker: PhantomData, +pub struct CreateJsonRpcIdFilter { + _marker: PhantomData<(Request, Response)>, } -impl CreateJsonRpcIdFilter { +impl CreateJsonRpcIdFilter { /// Create a new instance of [`CreateJsonRpcIdFilter`] pub fn new() -> Self { Self { @@ -301,7 +301,7 @@ impl CreateJsonRpcIdFilter { } } -impl Clone for CreateJsonRpcIdFilter { +impl Clone for CreateJsonRpcIdFilter { fn clone(&self) -> Self { Self { _marker: self._marker, @@ -309,38 +309,42 @@ impl Clone for CreateJsonRpcIdFilter { } } -impl Default for CreateJsonRpcIdFilter { +impl Default for CreateJsonRpcIdFilter { fn default() -> Self { Self::new() } } -impl CreateResponseFilter, http::Response> - for CreateJsonRpcIdFilter +impl CreateResponseFilter, http::Response> + for CreateJsonRpcIdFilter where - Call: JsonRpcCall, + (Request, Response): JsonRpcCall, + Request: Serialize, + Response: DeserializeOwned, { - type Filter = ConsistentJsonRpcIdFilter; + type Filter = ConsistentJsonRpcIdFilter; type Error = ConsistentResponseIdFilterError; - fn create_filter(&self, request: &http::Request) -> Self::Filter { + fn create_filter(&self, request: &http::Request) -> Self::Filter { ConsistentJsonRpcIdFilter::new(request) } } /// 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 where - Call: JsonRpcCall, + (Request, Response): JsonRpcCall, { - request_id: Call::Id, - _marker: PhantomData, + request_id: <(Request, Response) as JsonRpcCall>::Id, + _marker: PhantomData<(Request, Response)>, } -impl ConsistentJsonRpcIdFilter +impl ConsistentJsonRpcIdFilter where - Call: JsonRpcCall, + (Request, Response): JsonRpcCall, + Request: Serialize, + Response: DeserializeOwned, { /// Creates a new JSON-RPC filter to ensure that the response ID(s) match(es) the ID(s) of the /// given request. @@ -351,24 +355,28 @@ where /// 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)). - fn new(request: &http::Request) -> Self { + fn new(request: &http::Request) -> Self { Self { - request_id: Call::expected_response_id(request), + request_id: <(Request, Response)>::expected_response_id(request), _marker: PhantomData, } } } -impl Filter> for ConsistentJsonRpcIdFilter +impl Filter> + for ConsistentJsonRpcIdFilter where - Call: JsonRpcCall, + (Request, Response): JsonRpcCall, + Request: Serialize, + Response: DeserializeOwned, { type Error = ConsistentResponseIdFilterError; fn filter( &mut self, - response: http::Response, - ) -> Result, Self::Error> { - Call::has_consistent_response_id(&self.request_id, &response).map(|_| response) + response: http::Response, + ) -> Result, Self::Error> { + <(Request, Response)>::has_consistent_response_id(&self.request_id, &response) + .map(|_| response) } } diff --git a/canhttp/src/http/json/tests.rs b/canhttp/src/http/json/tests.rs index 43c8348..80d7381 100644 --- a/canhttp/src/http/json/tests.rs +++ b/canhttp/src/http/json/tests.rs @@ -283,10 +283,7 @@ mod filter_json_rpc_id { ) .unwrap(); let mut service = ServiceBuilder::new() - .filter_response(CreateJsonRpcIdFilter::<( - JsonRpcRequest<_>, - JsonRpcResponse<_>, - )>::new()) + .filter_response(CreateJsonRpcIdFilter::new()) .service_fn(|_request: HttpJsonRpcRequest| async { Ok::<_, BoxError>(http::Response::new(response.clone())) }); @@ -350,10 +347,7 @@ mod filter_json_rpc_id { #[should_panic(expected = "ERROR: a null request ID")] async fn should_panic_when_request_id_null() { let mut service = ServiceBuilder::new() - .filter_response(CreateJsonRpcIdFilter::<( - JsonRpcRequest<_>, - JsonRpcResponse<_>, - )>::new()) + .filter_response(CreateJsonRpcIdFilter::new()) .service_fn( |request: HttpJsonRpcRequest| async move { let id = request.body().id(); diff --git a/examples/json_rpc_canister/src/main.rs b/examples/json_rpc_canister/src/main.rs index ebd9822..2293289 100644 --- a/examples/json_rpc_canister/src/main.rs +++ b/examples/json_rpc_canister/src/main.rs @@ -1,15 +1,16 @@ //! Example of a canister using `canhttp` to issue JSON-RPC HTTP requests. + use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, - http::json::{ - BatchJsonRpcRequest, BatchJsonRpcResponse, Id, JsonRpcCall, JsonRpcHttpLayer, - JsonRpcRequest, JsonRpcResponse, - }, + http::json::{Id, JsonRpcCall, JsonRpcHttpLayer, JsonRpcRequest}, observability::ObservabilityLayer, Client, }; use ic_cdk::update; +use serde::de::DeserializeOwned; +use serde::Serialize; use serde_json::json; +use std::fmt::Debug; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; /// Make a JSON-RPC request to the Solana JSON-RPC API. @@ -24,7 +25,7 @@ pub async fn make_json_rpc_request() -> u64 { .body(JsonRpcRequest::new("getSlot", json!([{"commitment": "finalized"}])).with_id(ID)) .unwrap(); - let response = client::<(JsonRpcRequest, JsonRpcResponse)>() + let response = client() .ready() .await .expect("Client should be ready") @@ -53,16 +54,13 @@ pub async fn make_batch_json_rpc_request() -> Vec { ]) .unwrap(); - let response = client::<( - BatchJsonRpcRequest, - BatchJsonRpcResponse, - )>() - .ready() - .await - .expect("Client should be ready") - .call(requests) - .await - .expect("Request should succeed"); + let response = client() + .ready() + .await + .expect("Client should be ready") + .call(requests) + .await + .expect("Request should succeed"); assert_eq!(response.status(), http::StatusCode::OK); response @@ -77,28 +75,25 @@ pub async fn make_batch_json_rpc_request() -> Vec { .collect() } -fn client() -> impl Service< - http::Request, - Response = http::Response, - Error = BoxError, -> +fn client( +) -> impl Service, Response = http::Response, Error = BoxError> where - Call: JsonRpcCall, + (Request, Response): JsonRpcCall, + Request: Debug + Serialize, + Response: Debug + DeserializeOwned, { ServiceBuilder::new() .layer( ObservabilityLayer::new() - .on_request(|request: &http::Request| { - ic_cdk::println!("{request:?}") - }) - .on_response(|_, response: &http::Response| { + .on_request(|request: &http::Request| ic_cdk::println!("{request:?}")) + .on_response(|_, response: &http::Response| { ic_cdk::println!("{response:?}"); }) .on_error(|_, error: &BoxError| { ic_cdk::println!("Error {error:?}"); }), ) - .layer(JsonRpcHttpLayer::::new()) + .layer(JsonRpcHttpLayer::new()) .cycles_accounting(ChargeMyself::default()) .service(Client::new_with_box_error()) } From dce5081338323300eeb513a7e2214ed67695bfee Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 5 Jan 2026 14:34:30 +0100 Subject: [PATCH 5/6] Remove `JsonRpcCall` trait --- canhttp/src/http/json/mod.rs | 126 +-------------------- canhttp/src/http/json/response.rs | 149 +++++++++++++++++++------ examples/json_rpc_canister/src/main.rs | 53 +++++---- 3 files changed, 149 insertions(+), 179 deletions(-) diff --git a/canhttp/src/http/json/mod.rs b/canhttp/src/http/json/mod.rs index 6cb22e9..dd1e6ad 100644 --- a/canhttp/src/http/json/mod.rs +++ b/canhttp/src/http/json/mod.rs @@ -51,6 +51,7 @@ //! ``` //! //! [`Service`]: tower::Service +use crate::convert::CreateResponseFilter; use crate::{ convert::{ ConvertRequest, ConvertRequestLayer, ConvertResponse, ConvertResponseLayer, @@ -69,9 +70,7 @@ pub use response::{ JsonResponseConversionError, JsonResponseConverter, JsonRpcError, JsonRpcResponse, }; use serde::{de::DeserializeOwned, Serialize}; -use std::collections::BTreeSet; -use std::fmt::Debug; -use std::marker::PhantomData; +use std::{fmt::Debug, marker::PhantomData}; use tower_layer::{Layer, Stack}; pub use version::Version; @@ -177,9 +176,10 @@ impl Default for JsonRpcHttpLayer { impl Layer for JsonRpcHttpLayer where - (Request, Response): JsonRpcCall, Request: Serialize, Response: DeserializeOwned, + CreateJsonRpcIdFilter: + CreateResponseFilter, http::Response>, { type Service = FilterResponse< ConvertResponse< @@ -205,121 +205,3 @@ where fn stack(l1: L1, l2: L2, l3: L3) -> Stack> { Stack::new(l1, Stack::new(l2, l3)) } - -/// Represents a JSON-RPC request/response pair and its ID semantics. -/// -/// Defines the request and response types, the ID type, and how to generate -/// and verify that a response matches a request. -pub trait JsonRpcCall { - /// The type used to identify requests and responses. - type Id: Debug; - - /// Returns the expected response ID for a given request. - /// - /// # Panics - /// - /// Panics if the request ID is [`Id::Null`], which indicates a notification - /// (a request for which no response is expected). - fn expected_response_id(request: &http::Request) -> Self::Id; - - /// Checks that a response has a consistent ID for the given request ID. - /// - /// Returns `Ok(())` if the response ID is consistent, or - /// `ConsistentResponseIdFilterError` if it is not. - fn has_consistent_response_id( - request_id: &Self::Id, - response: &http::Response, - ) -> Result<(), ConsistentResponseIdFilterError>; -} - -impl JsonRpcCall, JsonRpcResponse> - for (JsonRpcRequest, JsonRpcResponse) -{ - type Id = Id; - - fn expected_response_id(request: &HttpJsonRpcRequest) -> Self::Id { - expected_response_id(request.body()) - } - - fn has_consistent_response_id( - request_id: &Id, - response: &HttpJsonRpcResponse, - ) -> std::result::Result<(), ConsistentResponseIdFilterError> { - let response_id = response.body().id(); - if request_id == response_id || should_have_null_id(response.body()) { - Ok(()) - } else { - Err(ConsistentResponseIdFilterError::InconsistentId { - status: response.status().into(), - request_id: request_id.clone(), - response_id: response_id.clone(), - }) - } - } -} - -impl JsonRpcCall, BatchJsonRpcResponse> - for (BatchJsonRpcRequest, BatchJsonRpcResponse) -{ - type Id = BTreeSet; - - fn expected_response_id(requests: &HttpBatchJsonRpcRequest) -> Self::Id { - requests - .body() - .iter() - .map(expected_response_id) - .collect::>() - } - - fn has_consistent_response_id( - request_ids: &Self::Id, - responses: &HttpBatchJsonRpcResponse, - ) -> std::result::Result<(), ConsistentResponseIdFilterError> { - 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(()) - } 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()) -} - -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/response.rs b/canhttp/src/http/json/response.rs index f6ee471..9d492d6 100644 --- a/canhttp/src/http/json/response.rs +++ b/canhttp/src/http/json/response.rs @@ -1,7 +1,8 @@ +use crate::http::json::BatchJsonRpcRequest; use crate::{ convert::{Convert, CreateResponseFilter, Filter}, http::{ - json::{Id, JsonRpcCall, Version}, + json::{HttpBatchJsonRpcRequest, HttpJsonRpcRequest, Id, JsonRpcRequest, Version}, HttpResponse, }, }; @@ -315,39 +316,51 @@ impl Default for CreateJsonRpcIdFilter { } } -impl CreateResponseFilter, http::Response> - for CreateJsonRpcIdFilter +impl CreateResponseFilter, HttpJsonRpcResponse> + for CreateJsonRpcIdFilter, JsonRpcResponse> where - (Request, Response): JsonRpcCall, - Request: Serialize, - Response: DeserializeOwned, + JsonRpcRequest: Serialize, + JsonRpcRequest: DeserializeOwned, { - type Filter = ConsistentJsonRpcIdFilter; + type Filter = ConsistentJsonRpcIdFilter, JsonRpcResponse, Id>; type Error = ConsistentResponseIdFilterError; - fn create_filter(&self, request: &http::Request) -> Self::Filter { - ConsistentJsonRpcIdFilter::new(request) + fn create_filter(&self, request: &HttpJsonRpcRequest) -> Self::Filter { + let request_id = expected_response_id(request.body()); + 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 +impl CreateResponseFilter, HttpBatchJsonRpcResponse> + for CreateJsonRpcIdFilter, BatchJsonRpcResponse> where - (Request, Response): JsonRpcCall, + BatchJsonRpcRequest: Serialize, + BatchJsonRpcResponse: DeserializeOwned, { - request_id: <(Request, Response) as JsonRpcCall>::Id, + 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 { + request_id: Id, _marker: PhantomData<(Request, Response)>, } -impl ConsistentJsonRpcIdFilter -where - (Request, Response): JsonRpcCall, - Request: Serialize, - Response: DeserializeOwned, -{ - /// Creates a new JSON-RPC filter to ensure that the response ID(s) match(es) the ID(s) of the - /// given request. +impl ConsistentJsonRpcIdFilter { + /// Creates a new JSON-RPC filter to ensure that the response ID(s) match(es) the given request + /// ID(s). /// /// # Panics /// @@ -355,28 +368,98 @@ where /// 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)). - fn new(request: &http::Request) -> Self { + fn new(request_id: Id) -> Self { Self { - request_id: <(Request, Response)>::expected_response_id(request), + request_id, _marker: PhantomData, } } } -impl Filter> - for ConsistentJsonRpcIdFilter +impl Filter> + for ConsistentJsonRpcIdFilter, JsonRpcResponse, Id> where - (Request, Response): JsonRpcCall, - Request: Serialize, - Response: DeserializeOwned, + JsonRpcRequest: Serialize, + JsonRpcRequest: DeserializeOwned, { type Error = ConsistentResponseIdFilterError; fn filter( &mut self, - response: http::Response, - ) -> Result, Self::Error> { - <(Request, Response)>::has_consistent_response_id(&self.request_id, &response) - .map(|_| response) + response: HttpJsonRpcResponse, + ) -> Result, Self::Error> { + 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; + + 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()) +} + +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/examples/json_rpc_canister/src/main.rs b/examples/json_rpc_canister/src/main.rs index 2293289..ac1477f 100644 --- a/examples/json_rpc_canister/src/main.rs +++ b/examples/json_rpc_canister/src/main.rs @@ -2,13 +2,11 @@ use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, - http::json::{Id, JsonRpcCall, JsonRpcHttpLayer, JsonRpcRequest}, + http::json::{Id, JsonRpcHttpLayer, JsonRpcRequest}, observability::ObservabilityLayer, Client, }; use ic_cdk::update; -use serde::de::DeserializeOwned; -use serde::Serialize; use serde_json::json; use std::fmt::Debug; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; @@ -25,7 +23,12 @@ pub async fn make_json_rpc_request() -> u64 { .body(JsonRpcRequest::new("getSlot", json!([{"commitment": "finalized"}])).with_id(ID)) .unwrap(); - let response = client() + let mut client = ServiceBuilder::new() + .layer(observability_layer()) + .layer(JsonRpcHttpLayer::new()) + .cycles_accounting(ChargeMyself::default()) + .service(Client::new_with_box_error()); + let response = client .ready() .await .expect("Client should be ready") @@ -54,7 +57,12 @@ pub async fn make_batch_json_rpc_request() -> Vec { ]) .unwrap(); - let response = client() + let mut client = ServiceBuilder::new() + .layer(observability_layer()) + .layer(JsonRpcHttpLayer::new()) + .cycles_accounting(ChargeMyself::default()) + .service(Client::new_with_box_error()); + let response = client .ready() .await .expect("Client should be ready") @@ -75,27 +83,24 @@ pub async fn make_batch_json_rpc_request() -> Vec { .collect() } -fn client( -) -> impl Service, Response = http::Response, Error = BoxError> +#[allow(clippy::type_complexity)] +fn observability_layer() -> ObservabilityLayer< + impl Fn(&Request) + Clone, + impl Fn((), &Response) + Clone, + impl Fn((), &BoxError) + Clone, +> where - (Request, Response): JsonRpcCall, - Request: Debug + Serialize, - Response: Debug + DeserializeOwned, + Request: Debug, + Response: Debug, { - ServiceBuilder::new() - .layer( - ObservabilityLayer::new() - .on_request(|request: &http::Request| ic_cdk::println!("{request:?}")) - .on_response(|_, response: &http::Response| { - ic_cdk::println!("{response:?}"); - }) - .on_error(|_, error: &BoxError| { - ic_cdk::println!("Error {error:?}"); - }), - ) - .layer(JsonRpcHttpLayer::new()) - .cycles_accounting(ChargeMyself::default()) - .service(Client::new_with_box_error()) + 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:?}"); + }) } fn solana_test_validator_base_url() -> String { From e72299a8f9f1d4de24bbdf16ee07c14abc414a60 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 6 Jan 2026 12:14:06 +0100 Subject: [PATCH 6/6] Cleanup example --- canhttp/src/http/json/mod.rs | 52 ++++++++++++++++++ canhttp/src/http/json/response.rs | 8 +-- examples/json_rpc_canister/src/main.rs | 75 ++++++++++++++++++-------- 3 files changed, 108 insertions(+), 27 deletions(-) diff --git a/canhttp/src/http/json/mod.rs b/canhttp/src/http/json/mod.rs index dd1e6ad..78d8f35 100644 --- a/canhttp/src/http/json/mod.rs +++ b/canhttp/src/http/json/mod.rs @@ -144,6 +144,58 @@ where /// 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)] diff --git a/canhttp/src/http/json/response.rs b/canhttp/src/http/json/response.rs index 9d492d6..4d3efdb 100644 --- a/canhttp/src/http/json/response.rs +++ b/canhttp/src/http/json/response.rs @@ -320,7 +320,7 @@ impl CreateResponseFilter, HttpJsonRpcResponse> for CreateJsonRpcIdFilter, JsonRpcResponse> where JsonRpcRequest: Serialize, - JsonRpcRequest: DeserializeOwned, + JsonRpcResponse: DeserializeOwned, { type Filter = ConsistentJsonRpcIdFilter, JsonRpcResponse, Id>; type Error = ConsistentResponseIdFilterError; @@ -335,7 +335,7 @@ impl CreateResponseFilter, HttpBatchJsonRpcResp for CreateJsonRpcIdFilter, BatchJsonRpcResponse> where BatchJsonRpcRequest: Serialize, - BatchJsonRpcResponse: DeserializeOwned, + BatchJsonRpcResponse: DeserializeOwned, { type Filter = ConsistentJsonRpcIdFilter, BatchJsonRpcResponse, BTreeSet>; @@ -380,7 +380,7 @@ impl Filter> for ConsistentJsonRpcIdFilter, JsonRpcResponse, Id> where JsonRpcRequest: Serialize, - JsonRpcRequest: DeserializeOwned, + JsonRpcResponse: DeserializeOwned, { type Error = ConsistentResponseIdFilterError; @@ -405,7 +405,7 @@ impl Filter> for ConsistentJsonRpcIdFilter, BatchJsonRpcResponse, BTreeSet> where BatchJsonRpcRequest: Serialize, - BatchJsonRpcResponse: DeserializeOwned, + BatchJsonRpcResponse: DeserializeOwned, { type Error = ConsistentResponseIdFilterError; diff --git a/examples/json_rpc_canister/src/main.rs b/examples/json_rpc_canister/src/main.rs index ac1477f..a74c97a 100644 --- a/examples/json_rpc_canister/src/main.rs +++ b/examples/json_rpc_canister/src/main.rs @@ -2,11 +2,15 @@ use canhttp::{ cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, - http::json::{Id, JsonRpcHttpLayer, JsonRpcRequest}, + http::json::{ + HttpBatchJsonRpcRequest, HttpBatchJsonRpcResponse, HttpJsonRpcRequest, HttpJsonRpcResponse, + Id, JsonRpcHttpLayer, JsonRpcRequest, + }, observability::ObservabilityLayer, Client, }; use ic_cdk::update; +use serde::{de::DeserializeOwned, Serialize}; use serde_json::json; use std::fmt::Debug; use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; @@ -23,12 +27,7 @@ pub async fn make_json_rpc_request() -> u64 { .body(JsonRpcRequest::new("getSlot", json!([{"commitment": "finalized"}])).with_id(ID)) .unwrap(); - let mut client = ServiceBuilder::new() - .layer(observability_layer()) - .layer(JsonRpcHttpLayer::new()) - .cycles_accounting(ChargeMyself::default()) - .service(Client::new_with_box_error()); - let response = client + let response = json_rpc_client() .ready() .await .expect("Client should be ready") @@ -43,6 +42,23 @@ pub async fn make_json_rpc_request() -> u64 { result.expect("JSON-RPC API call should succeed") } +fn json_rpc_client( +) -> impl Service, Response = HttpJsonRpcResponse, 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 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 { @@ -57,12 +73,7 @@ pub async fn make_batch_json_rpc_request() -> Vec { ]) .unwrap(); - let mut client = ServiceBuilder::new() - .layer(observability_layer()) - .layer(JsonRpcHttpLayer::new()) - .cycles_accounting(ChargeMyself::default()) - .service(Client::new_with_box_error()); - let response = client + let response = batch_json_rpc_client() .ready() .await .expect("Client should be ready") @@ -83,26 +94,44 @@ pub async fn make_batch_json_rpc_request() -> Vec { .collect() } -#[allow(clippy::type_complexity)] -fn observability_layer() -> ObservabilityLayer< - impl Fn(&Request) + Clone, - impl Fn((), &Response) + Clone, - impl Fn((), &BoxError) + Clone, +fn batch_json_rpc_client() -> impl Service< + HttpBatchJsonRpcRequest, + Response = HttpBatchJsonRpcResponse, + Error = BoxError, > where - Request: Debug, - Response: Debug, + 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| { + .on_request::>(|request: &Request| { + ic_cdk::println!("{request:?}"); + }) + .on_response::>(|_, response: &Response| { ic_cdk::println!("{response:?}"); }) - .on_error(|_, error: &BoxError| { + .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.mainnet-beta.solana.com")