Skip to content
Open
74 changes: 56 additions & 18 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion canhttp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@ documentation = "https://docs.rs/canhttp"
[features]
default = ["http"]
http = ["dep:http", "dep:num-traits", "dep:tower-layer"]
json = ["http", "dep:serde", "dep:serde_json"]
json = ["dep:derive_more", "dep:http", "dep:serde", "dep:serde_json"]
multi = ["dep:ciborium", "dep:sha2", "dep:futures-channel", "dep:serde"]

[dependencies]
assert_matches = { workspace = true }
ciborium = { workspace = true, optional = true }
derive_more = { workspace = true, optional = true }
futures-channel = { workspace = true, optional = true }
futures-util = { workspace = true }
http = { workspace = true, optional = true }
ic-cdk = { workspace = true }
ic-error-types = { workspace = true }
itertools = { workspace = true }
num-traits = { workspace = true, optional = true }
pin-project = { workspace = true }
serde = { workspace = true, optional = true }
Expand Down
10 changes: 6 additions & 4 deletions canhttp/src/http/json/id.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
99 changes: 79 additions & 20 deletions canhttp/src/http/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
//! ```
//!
//! [`Service`]: tower::Service

use crate::convert::CreateResponseFilter;
use crate::{
convert::{
ConvertRequest, ConvertRequestLayer, ConvertResponse, ConvertResponseLayer,
Expand All @@ -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;

Expand Down Expand Up @@ -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<Params, Result>() -> impl Service<
/// HttpJsonRpcRequest<Params>,
/// Response = HttpJsonRpcResponse<Result>,
/// 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems very similar to the previous example and it's a priori difficult to see what the exact difference is. Maybe we could have a .batch method that transforms a service Service< HttpJsonRpcRequest<Params>, HttpJsonRpcResponse<Result>, Error > into an Service< HttpBatchJsonRpcRequest<Params>, HttpBatchJsonRpcResponse<Result>, Error >?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that would be possible because we would want to modify some of the internal middlewares within the service to achieve that. See this thread, but it's possible to build a generic method for the client that handles both types of requests.

/// ```
/// use canhttp::{
/// Client,
/// http::json::{HttpBatchJsonRpcRequest, HttpBatchJsonRpcResponse, JsonRpcHttpLayer}
/// };
/// use serde::{de::DeserializeOwned, Serialize};
/// use std::fmt::Debug;
/// use tower::{BoxError, Service, ServiceBuilder};
///
/// fn client<Params, Result>() -> impl Service<
/// HttpBatchJsonRpcRequest<Params>,
/// Response = HttpBatchJsonRpcResponse<Result>,
/// 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<Params, Result> {
_marker: PhantomData<(Params, Result)>,
pub struct JsonRpcHttpLayer<Request, Response> {
_marker: PhantomData<(Request, Response)>,
}

impl<Params, Result> JsonRpcHttpLayer<Params, Result> {
impl<Request, Response> JsonRpcHttpLayer<Request, Response> {
/// Returns a new [`JsonRpcHttpLayer`].
pub fn new() -> Self {
Self {
Expand All @@ -155,40 +212,42 @@ impl<Params, Result> JsonRpcHttpLayer<Params, Result> {
}
}

impl<Params, Result> Clone for JsonRpcHttpLayer<Params, Result> {
impl<Request, Response> Clone for JsonRpcHttpLayer<Request, Response> {
fn clone(&self) -> Self {
Self {
_marker: self._marker,
}
}
}

impl<Params, Result> Default for JsonRpcHttpLayer<Params, Result> {
impl<Request, Response> Default for JsonRpcHttpLayer<Request, Response> {
fn default() -> Self {
Self::new()
}
}

impl<Params, Result, S> Layer<S> for JsonRpcHttpLayer<Params, Result>
impl<Request, Response, S> Layer<S> for JsonRpcHttpLayer<Request, Response>
where
Params: Serialize,
Result: DeserializeOwned,
Request: Serialize,
Response: DeserializeOwned,
CreateJsonRpcIdFilter<Request, Response>:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

understanding question: why do we need this new trait bound?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because the generic changed from just the contents of a JsonRpcRequest/JsonRpcResponse to now the contents of the http::Request/http::Response.

CreateResponseFilter<http::Request<Request>, http::Response<Response>>,
{
type Service = FilterResponse<
ConvertResponse<
ConvertRequest<
ConvertResponse<ConvertRequest<S, HttpRequestConverter>, HttpResponseConverter>,
JsonRequestConverter<JsonRpcRequest<Params>>,
JsonRequestConverter<Request>,
>,
JsonResponseConverter<JsonRpcResponse<Result>>,
JsonResponseConverter<Response>,
>,
CreateJsonRpcIdFilter<Params, Result>,
CreateJsonRpcIdFilter<Request, Response>,
>;

fn layer(&self, inner: S) -> Self::Service {
stack(
HttpConversionLayer,
JsonConversionLayer::<JsonRpcRequest<Params>, JsonRpcResponse<Result>>::new(),
JsonConversionLayer::<Request, Response>::new(),
CreateResponseFilterLayer::new(CreateJsonRpcIdFilter::new()),
)
.layer(inner)
Expand Down
Loading