Repository: sugyan/atrium (language: rust)

Issue:
reqwest with rusttls feature flag
Hi,

It'd be nice to make an `atrium-xrpc` feature flag that would trickle down to the `reqwest` `rusttls` feature flag


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/xrpc-client.yml b/.github/workflows/xrpc-client.yml
new file mode 100644
index 00000000..c79f02c1
--- /dev/null
+++ b/.github/workflows/xrpc-client.yml
@@ -0,0 +1,28 @@
+name: XRPC Client
+
+on:
+  push:
+    branches: ["main"]
+  pull_request:
+    branches: ["main"]
+
+env:
+  CARGO_TERM_COLOR: always
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Build
+        run: cargo build -p atrium-xrpc-client --verbose
+      - name: Run tests
+        run: |
+          cargo test -p atrium-xrpc-client --lib
+          cargo test -p atrium-xrpc-client --lib --no-default-features --features=reqwest-native
+          cargo test -p atrium-xrpc-client --lib --no-default-features --features=reqwest-rustls
+          cargo test -p atrium-xrpc-client --lib --no-default-features --features=isahc
+          cargo test -p atrium-xrpc-client --lib --no-default-features --features=surf
+          cargo test -p atrium-xrpc-client --lib --all-features
+      - name: Run doctests
+        run: cargo test -p atrium-xrpc-client --doc --all-features
diff --git a/Cargo.toml b/Cargo.toml
index 927c9f26..be6e4850 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,7 @@ members = [
     "atrium-codegen",
     "atrium-lex",
     "atrium-xrpc",
+    "atrium-xrpc-client",
     "atrium-xrpc-server",
     "examples/concurrent",
     "examples/firehose",
diff --git a/atrium-xrpc-client/Cargo.toml b/atrium-xrpc-client/Cargo.toml
new file mode 100644
index 00000000..91f9aff6
--- /dev/null
+++ b/atrium-xrpc-client/Cargo.toml
@@ -0,0 +1,49 @@
+[package]
+name = "atrium-xrpc-client"
+version = "0.1.0"
+authors = ["sugyan <sugi1982+github@gmail.com>"]
+edition = "2021"
+description = "XRPC Client library for AT Protocol (Bluesky)"
+documentation = "https://docs.rs/atrium-xrpc-client"
+readme = "README.md"
+repository = "https://github.com/sugyan/atrium"
+license = "MIT"
+keywords = ["atproto", "bluesky"]
+
+[dependencies]
+async-trait = "0.1.74"
+atrium-xrpc = "0.5.0"
+http = "0.2.9"
+
+[dependencies.isahc]
+version = "1.7.2"
+optional = true
+
+[dependencies.reqwest]
+version = "0.11.22"
+default-features = false
+optional = true
+
+[dependencies.surf]
+version = "2.3.2"
+default-features = false
+optional = true
+
+[features]
+default = ["reqwest-native"]
+isahc = ["dep:isahc"]
+reqwest-native = ["reqwest/native-tls"]
+reqwest-rustls = ["reqwest/rustls-tls"]
+surf = ["dep:surf"]
+
+[dev-dependencies]
+surf = { version = "2.3.2", default-features = false, features = ["h1-client-rustls"] }
+http-client = { version = "6.5.3", default-features = false, features = ["h1_client", "rustls"] }
+mockito = "1.2.0"
+tokio = { version = "1.33.0", features = ["macros"] }
+serde = { version = "1.0.192", features = ["derive"] }
+futures = { version = "0.3.29", default-features = false }
+
+[package.metadata.docs.rs]
+all-features = true
+rustdoc-args = ["--cfg", "docsrs"]
diff --git a/atrium-xrpc-client/README.md b/atrium-xrpc-client/README.md
new file mode 100644
index 00000000..a59e5627
--- /dev/null
+++ b/atrium-xrpc-client/README.md
@@ -0,0 +1,157 @@
+# ATrium XRPC Client
+
+This library provides clients that implement the [`XrpcClient`](https://docs.rs/atrium-xrpc/latest/atrium_xrpc/trait.XrpcClient.html) defined in [`atrium-xrpc`](../atrium-xrpc/). To accommodate a wide range of use cases, four feature flags are provided to allow developers to choose the best asynchronous HTTP client library for their project as a backend.
+
+## Features
+
+- `reqwest-native` (default)
+- `reqwest-rustls`
+- `isahc`
+- `surf`
+
+Usage examples are provided below.
+
+### `reqwest-native` and `reqwest-rustls`
+
+If you are using [`tokio`](https://crates.io/crates/tokio) as your asynchronous runtime, you may find it convenient to utilize the [`reqwest`](https://crates.io/crates/reqwest) backend with this feature, which is a high-level asynchronous HTTP Client. Within this crate, you have the choice of configuring `reqwest` with either `reqwest/native-tls` or `reqwest/rustls-tls`.
+
+```toml
+[dependencies]
+atrium-xrpc-client = "*"
+```
+
+To use the `reqwest::Client` with the `rustls` TLS backend, specify the feature as follows:
+
+```toml
+[dependencies]
+atrium-xrpc-client = { version = "*", default-features = false, features = ["reqwest-rustls"]}
+```
+
+In either case, you can use the `ReqwestClient`:
+
+```rust
+use atrium_xrpc_client::reqwest::ReqwestClient;
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let client = ReqwestClient::new("https://bsky.social");
+    Ok(())
+}
+```
+
+You can also directly specify a `reqwest::Client` with your own configuration:
+
+```toml
+[dependencies]
+atrium-xrpc-client = { version = "*", default-features = false }
+reqwest = { version = "0.11.22", default-features = false, features = ["rustls-tls"] }
+```
+
+```rust
+use atrium_xrpc_client::reqwest::ReqwestClientBuilder;
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let client = ReqwestClientBuilder::new("https://bsky.social")
+        .client(
+            reqwest::ClientBuilder::new()
+                .timeout(std::time::Duration::from_millis(1000))
+                .use_rustls_tls()
+                .build()?,
+        )
+        .build();
+    Ok(())
+}
+```
+
+For more details, refer to the [`reqwest` documentation](https://docs.rs/reqwest).
+
+### `isahc`
+
+The `reqwest` client may not work on asynchronous runtimes other than `tokio`. As an alternative, we offer the feature that uses [`isahc`](https://crates.io/crates/isahc) as the backend.
+
+```toml
+[dependencies]
+atrium-xrpc-client = { version = "*", default-features = false, features = ["isahc"]}
+```
+
+```rust
+use atrium_xrpc_client::isahc::IsahcClient;
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let client = IsahcClient::new("https://bsky.social");
+    Ok(())
+}
+```
+
+Similarly, you can directly specify an isahc::HttpClient with your own settings:
+
+```toml
+[dependencies]
+atrium-xrpc-client = { version = "*", default-features = false, features = ["isahc"]}
+isahc = "1.7.2"
+```
+
+```rust
+use atrium_xrpc_client::isahc::IsahcClientBuilder;
+use isahc::config::Configurable;
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let client = IsahcClientBuilder::new("https://bsky.social")
+        .client(
+            isahc::HttpClientBuilder::new()
+                .timeout(std::time::Duration::from_millis(1000))
+                .build()?,
+        )
+        .build();
+    Ok(())
+}
+```
+
+For more details, refer to the [`isahc` documentation](https://docs.rs/isahc).
+
+
+### `surf`
+
+For cases such as using `rustls` with asynchronous runtimes other than `tokio`, we also provide a feature that uses [`surf`](https://crates.io/crates/surf) built with [`async-std`](https://crates.io/crates/async-std) as a backend.
+
+Using `DefaultClient` with `surf` is complicated by the various feature flags. Therefore, unlike the first two options, you must always specify surf::Client when creating a client with this module.
+
+```toml
+[dependencies]
+atrium-xrpc-client = { version = "*", default-features = false, features = ["surf"]}
+surf = { version = "2.3.2", default-features = false, features = ["h1-client-rustls"] }
+```
+
+```rust
+use atrium_xrpc_client::surf::SurfClient;
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let client = SurfClient::new("https://bsky.social", surf::Client::new());
+    Ok(())
+}
+```
+
+Using [`http_client`](https://crates.io/crates/http-client) and its bundled implementation may clarify which backend you are using:
+
+```toml
+[dependencies]
+atrium-xrpc-client = { version = "*", default-features = false, features = ["surf"]}
+surf = { version = "2.3.2", default-features = false }
+http-client = { version = "6.5.3", default-features = false, features = ["h1_client", "rustls"] }
+```
+
+```rust
+use atrium_xrpc_client::surf::SurfClient;
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let client = SurfClient::new(
+        "https://bsky.social",
+        surf::Client::with_http_client(http_client::h1::H1Client::try_from(
+            http_client::Config::default()
+                .set_timeout(Some(std::time::Duration::from_millis(1000))),
+        )?),
+    );
+    Ok(())
+}
+```
+
+For more details, refer to the [`surf` documentation](https://docs.rs/surf).
diff --git a/atrium-xrpc-client/src/isahc.rs b/atrium-xrpc-client/src/isahc.rs
new file mode 100644
index 00000000..688f7e6e
--- /dev/null
+++ b/atrium-xrpc-client/src/isahc.rs
@@ -0,0 +1,116 @@
+#![doc = "XrpcClient implementation for [isahc]"]
+use async_trait::async_trait;
+use atrium_xrpc::{HttpClient, XrpcClient};
+use http::{Request, Response};
+use isahc::{AsyncReadResponseExt, HttpClient as Client};
+use std::sync::Arc;
+
+/// A [`isahc`] based asynchronous client to make XRPC requests with.
+///
+/// To change the [`isahc::HttpClient`] used internally to a custom configured one,
+/// use the [`IsahcClientBuilder`].
+///
+/// You do **not** have to wrap the `Client` in an [`Rc`] or [`Arc`] to **reuse** it,
+/// because it already uses an [`Arc`] internally.
+///
+/// [`Rc`]: std::rc::Rc
+pub struct IsahcClient {
+    base_uri: String,
+    client: Arc<Client>,
+}
+
+impl IsahcClient {
+    /// Create a new [`IsahcClient`] using the default configuration.
+    pub fn new(base_uri: impl AsRef<str>) -> Self {
+        IsahcClientBuilder::new(base_uri).build()
+    }
+}
+
+/// A client builder, capable of creating custom [`IsahcClient`] instances.
+pub struct IsahcClientBuilder {
+    base_uri: String,
+    client: Option<Client>,
+}
+
+impl IsahcClientBuilder {
+    /// Create a new [`IsahcClientBuilder`] for building a custom client.
+    pub fn new(base_uri: impl AsRef<str>) -> Self {
+        Self {
+            base_uri: base_uri.as_ref().into(),
+            client: None,
+        }
+    }
+    /// Sets the [`isahc::HttpClient`] to use.
+    pub fn client(mut self, client: Client) -> Self {
+        self.client = Some(client);
+        self
+    }
+    /// Build an [`IsahcClient`] using the configured options.
+    pub fn build(self) -> IsahcClient {
+        IsahcClient {
+            base_uri: self.base_uri,
+            client: Arc::new(
+                self.client
+                    .unwrap_or(Client::new().expect("failed to create isahc client")),
+            ),
+        }
+    }
+}
+
+#[async_trait]
+impl HttpClient for IsahcClient {
+    async fn send_http(
+        &self,
+        request: Request<Vec<u8>>,
+    ) -> Result<Response<Vec<u8>>, Box<dyn std::error::Error + Send + Sync + 'static>> {
+        let mut response = self.client.send_async(request).await?;
+        let mut builder = Response::builder().status(response.status());
+        for (k, v) in response.headers() {
+            builder = builder.header(k, v);
+        }
+        builder
+            .body(response.bytes().await?.to_vec())
+            .map_err(Into::into)
+    }
+}
+
+impl XrpcClient for IsahcClient {
+    fn base_uri(&self) -> &str {
+        &self.base_uri
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use isahc::config::Configurable;
+    use std::time::Duration;
+
+    #[test]
+    fn new() -> Result<(), Box<dyn std::error::Error>> {
+        let client = IsahcClient::new("http://localhost:8080");
+        assert_eq!(client.base_uri(), "http://localhost:8080");
+        Ok(())
+    }
+
+    #[test]
+    fn builder_without_client() -> Result<(), Box<dyn std::error::Error>> {
+        let client = IsahcClientBuilder::new("http://localhost:8080").build();
+        assert_eq!(client.base_uri(), "http://localhost:8080");
+        Ok(())
+    }
+
+    #[test]
+    fn builder_with_client() -> Result<(), Box<dyn std::error::Error>> {
+        let client = IsahcClientBuilder::new("http://localhost:8080")
+            .client(
+                Client::builder()
+                    .default_header(http::header::USER_AGENT, "USER_AGENT")
+                    .timeout(Duration::from_millis(500))
+                    .build()?,
+            )
+            .build();
+        assert_eq!(client.base_uri(), "http://localhost:8080");
+        Ok(())
+    }
+}
diff --git a/atrium-xrpc-client/src/lib.rs b/atrium-xrpc-client/src/lib.rs
new file mode 100644
index 00000000..409abdf6
--- /dev/null
+++ b/atrium-xrpc-client/src/lib.rs
@@ -0,0 +1,18 @@
+#![cfg_attr(docsrs, feature(doc_cfg))]
+#![doc = include_str!("../README.md")]
+
+#[cfg_attr(docsrs, doc(cfg(feature = "isahc")))]
+#[cfg(feature = "isahc")]
+pub mod isahc;
+#[cfg_attr(
+    docsrs,
+    doc(cfg(any(feature = "reqwest-native", feature = "reqwest-rustls")))
+)]
+#[cfg(any(feature = "reqwest-native", feature = "reqwest-rustls"))]
+pub mod reqwest;
+#[cfg_attr(docsrs, doc(cfg(feature = "surf")))]
+#[cfg(feature = "surf")]
+pub mod surf;
+
+#[cfg(test)]
+mod tests;
diff --git a/atrium-xrpc-client/src/reqwest.rs b/atrium-xrpc-client/src/reqwest.rs
new file mode 100644
index 00000000..8af4ec4d
--- /dev/null
+++ b/atrium-xrpc-client/src/reqwest.rs
@@ -0,0 +1,112 @@
+#![doc = "XrpcClient implementation for [reqwest]"]
+use async_trait::async_trait;
+use atrium_xrpc::{HttpClient, XrpcClient};
+use http::{Request, Response};
+use reqwest::Client;
+use std::sync::Arc;
+
+/// A [`reqwest`] based asynchronous client to make XRPC requests with.
+///
+/// To change the [`reqwest::Client`] used internally to a custom configured one,
+/// use the [`ReqwestClientBuilder`].
+///
+/// You do **not** have to wrap the `Client` in an [`Rc`] or [`Arc`] to **reuse** it,
+/// because it already uses an [`Arc`] internally.
+///
+/// [`Rc`]: std::rc::Rc
+pub struct ReqwestClient {
+    base_uri: String,
+    client: Arc<Client>,
+}
+
+impl ReqwestClient {
+    /// Create a new [`ReqwestClient`] using the default configuration.
+    pub fn new(base_uri: impl AsRef<str>) -> ReqwestClient {
+        ReqwestClientBuilder::new(base_uri).build()
+    }
+}
+
+/// A client builder, capable of creating custom [`ReqwestClient`] instances.
+pub struct ReqwestClientBuilder {
+    base_uri: String,
+    client: Option<Client>,
+}
+
+impl ReqwestClientBuilder {
+    /// Create a new [`ReqwestClientBuilder`] for building a custom client.
+    pub fn new(base_uri: impl AsRef<str>) -> Self {
+        Self {
+            base_uri: base_uri.as_ref().into(),
+            client: None,
+        }
+    }
+    /// Sets the [`reqwest::Client`] to use.
+    pub fn client(mut self, client: Client) -> Self {
+        self.client = Some(client);
+        self
+    }
+    /// Build an [`ReqwestClient`] using the configured options.
+    pub fn build(self) -> ReqwestClient {
+        ReqwestClient {
+            base_uri: self.base_uri,
+            client: Arc::new(self.client.unwrap_or_default()),
+        }
+    }
+}
+
+#[async_trait]
+impl HttpClient for ReqwestClient {
+    async fn send_http(
+        &self,
+        request: Request<Vec<u8>>,
+    ) -> Result<Response<Vec<u8>>, Box<dyn std::error::Error + Send + Sync + 'static>> {
+        let response = self.client.execute(request.try_into()?).await?;
+        let mut builder = Response::builder().status(response.status());
+        for (k, v) in response.headers() {
+            builder = builder.header(k, v);
+        }
+        builder
+            .body(response.bytes().await?.to_vec())
+            .map_err(Into::into)
+    }
+}
+
+impl XrpcClient for ReqwestClient {
+    fn base_uri(&self) -> &str {
+        &self.base_uri
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::time::Duration;
+
+    #[test]
+    fn new() -> Result<(), Box<dyn std::error::Error>> {
+        let client = ReqwestClient::new("http://localhost:8080");
+        assert_eq!(client.base_uri(), "http://localhost:8080");
+        Ok(())
+    }
+
+    #[test]
+    fn builder_without_client() -> Result<(), Box<dyn std::error::Error>> {
+        let client = ReqwestClientBuilder::new("http://localhost:8080").build();
+        assert_eq!(client.base_uri(), "http://localhost:8080");
+        Ok(())
+    }
+
+    #[test]
+    fn builder_with_client() -> Result<(), Box<dyn std::error::Error>> {
+        let client = ReqwestClientBuilder::new("http://localhost:8080")
+            .client(
+                Client::builder()
+                    .user_agent("USER_AGENT")
+                    .timeout(Duration::from_millis(500))
+                    .build()?,
+            )
+            .build();
+        assert_eq!(client.base_uri(), "http://localhost:8080");
+        Ok(())
+    }
+}
diff --git a/atrium-xrpc-client/src/surf.rs b/atrium-xrpc-client/src/surf.rs
new file mode 100644
index 00000000..9dd3d44e
--- /dev/null
+++ b/atrium-xrpc-client/src/surf.rs
@@ -0,0 +1,90 @@
+#![doc = "XrpcClient implementation for [surf]"]
+use async_trait::async_trait;
+use atrium_xrpc::{HttpClient, XrpcClient};
+use http::{Request, Response};
+use std::sync::Arc;
+use surf::Client;
+
+/// A [`surf`] based asynchronous client to make XRPC requests with.
+///
+/// You do **not** have to wrap the `Client` in an [`Rc`] or [`Arc`] to **reuse** it,
+/// because it already uses an [`Arc`] internally.
+///
+/// [`Rc`]: std::rc::Rc
+pub struct SurfClient {
+    base_uri: String,
+    client: Arc<Client>,
+}
+
+impl SurfClient {
+    /// Create a new [`SurfClient`] using the passed [`surf::Client`].
+    pub fn new(base_uri: impl AsRef<str>, client: Client) -> Self {
+        Self {
+            base_uri: base_uri.as_ref().to_string(),
+            client: Arc::new(client),
+        }
+    }
+}
+
+#[async_trait]
+impl HttpClient for SurfClient {
+    async fn send_http(
+        &self,
+        request: Request<Vec<u8>>,
+    ) -> Result<Response<Vec<u8>>, Box<dyn std::error::Error + Send + Sync + 'static>> {
+        let method = match *request.method() {
+            http::Method::GET => surf::http::Method::Get,
+            http::Method::POST => surf::http::Method::Post,
+            _ => unimplemented!(),
+        };
+        let url = surf::http::Url::parse(&request.uri().to_string())?;
+        let mut req_builder = surf::RequestBuilder::new(method, url);
+        for (name, value) in request.headers() {
+            req_builder = req_builder.header(name.as_str(), value.to_str()?);
+        }
+        let mut response = self
+            .client
+            .send(req_builder.body(request.body().to_vec()).build())
+            .await?;
+        let mut res_builder = Response::builder();
+        for (name, values) in response.iter() {
+            for value in values {
+                res_builder = res_builder.header(name.as_str(), value.as_str());
+            }
+        }
+        res_builder
+            .status(u16::from(response.status()))
+            .body(response.body_bytes().await?)
+            .map_err(Into::into)
+    }
+}
+
+impl XrpcClient for SurfClient {
+    fn base_uri(&self) -> &str {
+        &self.base_uri
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use http_client::h1::H1Client;
+    use std::time::Duration;
+
+    #[test]
+    fn new() -> Result<(), Box<dyn std::error::Error>> {
+        let client = SurfClient::new(
+            "http://localhost:8080",
+            Client::try_from(
+                surf::Config::default()
+                    .set_http_client(H1Client::try_from(
+                        http_client::Config::default()
+                            .set_timeout(Some(Duration::from_millis(500))),
+                    )?)
+                    .add_header(surf::http::headers::USER_AGENT, "USER_AGENT")?,
+            )?,
+        );
+        assert_eq!(client.base_uri(), "http://localhost:8080");
+        Ok(())
+    }
+}
diff --git a/release-plz.toml b/release-plz.toml
index 13fd37d8..f8548abf 100644
--- a/release-plz.toml
+++ b/release-plz.toml
@@ -12,6 +12,11 @@ name = "atrium-xrpc"
 publish = true
 changelog_update = true
 
+[[package]]
+name = "atrium-xrpc-client"
+publish = true
+changelog_update = true
+
 [[package]]
 name = "atrium-cli"
 changelog_update = true


Test patch:
diff --git a/atrium-xrpc-client/src/tests.rs b/atrium-xrpc-client/src/tests.rs
new file mode 100644
index 00000000..0755feba
--- /dev/null
+++ b/atrium-xrpc-client/src/tests.rs
@@ -0,0 +1,315 @@
+use atrium_xrpc::{InputDataOrBytes, OutputDataOrBytes, XrpcClient, XrpcRequest};
+use futures::future::join_all;
+use http::Method;
+use mockito::{Matcher, Server};
+use serde::{Deserialize, Serialize};
+use tokio::task::JoinError;
+
+#[derive(Serialize, Deserialize, Debug)]
+struct Parameters {
+    query: String,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct Input {
+    data: String,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct Output {
+    data: String,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+#[serde(tag = "error", content = "message")]
+enum Error {
+    Bad,
+}
+
+async fn run_query(
+    client: impl XrpcClient + Send + Sync,
+    path: String,
+) -> Result<Output, atrium_xrpc::error::Error<Error>> {
+    let response = client
+        .send_xrpc::<_, (), _, _>(&XrpcRequest {
+            method: Method::GET,
+            path,
+            parameters: Some(Parameters {
+                query: "foo".into(),
+            }),
+            input: None,
+            encoding: None,
+        })
+        .await?;
+    match response {
+        OutputDataOrBytes::Bytes(_) => Err(atrium_xrpc::error::Error::UnexpectedResponseType),
+        OutputDataOrBytes::Data(out) => Ok(out),
+    }
+}
+
+async fn run_procedure(
+    client: impl XrpcClient + Send + Sync,
+    path: String,
+) -> Result<Output, atrium_xrpc::error::Error<Error>> {
+    let response = client
+        .send_xrpc::<(), _, _, _>(&XrpcRequest {
+            method: Method::POST,
+            path,
+            parameters: None,
+            input: Some(InputDataOrBytes::Data(Input { data: "foo".into() })),
+            encoding: Some("application/json".into()),
+        })
+        .await?;
+    match response {
+        OutputDataOrBytes::Bytes(_) => Err(atrium_xrpc::error::Error::UnexpectedResponseType),
+        OutputDataOrBytes::Data(out) => Ok(out),
+    }
+}
+
+#[tokio::test]
+async fn send_query() -> Result<(), Box<dyn std::error::Error>> {
+    let mut server = Server::new_async().await;
+    let mock_ok = server
+        .mock("GET", "/xrpc/test/ok")
+        .match_query(Matcher::UrlEncoded("query".into(), "foo".into()))
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"data": "bar"}"#)
+        .create_async()
+        .await;
+    let mock_err = server
+        .mock("GET", "/xrpc/test/err")
+        .match_query(Matcher::UrlEncoded("query".into(), "foo".into()))
+        .with_status(400)
+        .with_body(r#"{"error": "Bad"}"#)
+        .create_async()
+        .await;
+    let mock_server_error = server
+        .mock("GET", "/xrpc/test/500")
+        .match_query(Matcher::Any)
+        .with_status(500)
+        .create_async()
+        .await;
+
+    async fn run(
+        base_uri: &str,
+        path: &str,
+    ) -> Vec<Result<Result<Output, atrium_xrpc::error::Error<Error>>, JoinError>> {
+        let handles = vec![
+            #[cfg(feature = "isahc")]
+            tokio::spawn(run_query(
+                crate::isahc::IsahcClientBuilder::new(base_uri)
+                    .client(
+                        isahc::HttpClient::builder()
+                            .build()
+                            .expect("client should be successfully built"),
+                    )
+                    .build(),
+                path.to_string(),
+            )),
+            #[cfg(feature = "reqwest-native")]
+            tokio::spawn(run_query(
+                crate::reqwest::ReqwestClientBuilder::new(base_uri)
+                    .client(
+                        reqwest::ClientBuilder::new()
+                            .use_native_tls()
+                            .build()
+                            .expect("client should be successfully built"),
+                    )
+                    .build(),
+                path.to_string(),
+            )),
+            #[cfg(feature = "reqwest-rustls")]
+            tokio::spawn(run_query(
+                crate::reqwest::ReqwestClientBuilder::new(base_uri)
+                    .client(
+                        reqwest::ClientBuilder::new()
+                            .use_rustls_tls()
+                            .build()
+                            .expect("client should be successfully built"),
+                    )
+                    .build(),
+                path.to_string(),
+            )),
+            #[cfg(feature = "surf")]
+            tokio::spawn(run_query(
+                crate::surf::SurfClient::new(
+                    base_uri,
+                    surf::Client::with_http_client(http_client::h1::H1Client::new()),
+                ),
+                path.to_string(),
+            )),
+        ];
+        join_all(handles).await
+    }
+
+    // Ok
+    {
+        let results = run(&server.url(), "test/ok").await;
+        let len = results.len();
+        for result in results {
+            let output = result?.expect("xrpc response should be ok");
+            assert_eq!(output.data, "bar");
+        }
+        mock_ok.expect(len).assert_async().await;
+    }
+    // Err (XrpcError)
+    {
+        let results = run(&server.url(), "test/err").await;
+        let len = results.len();
+        for result in results {
+            let err = result?.expect_err("xrpc response should be error");
+            if let atrium_xrpc::error::Error::XrpcResponse(e) = err {
+                assert_eq!(e.status, 400);
+                if let Some(atrium_xrpc::error::XrpcErrorKind::Custom(Error::Bad)) = e.error {
+                } else {
+                    panic!("unexpected error kind: {e:?}");
+                }
+            } else {
+                panic!("unexpected error: {err:?}");
+            }
+        }
+        mock_err.expect(len).assert_async().await;
+    }
+    // Err (server error)
+    {
+        let results = run(&server.url(), "test/500").await;
+        let len = results.len();
+        for result in results {
+            let err = result?.expect_err("xrpc response should be error");
+            if let atrium_xrpc::error::Error::XrpcResponse(e) = err {
+                assert_eq!(e.status, 500);
+                assert!(e.error.is_none());
+            } else {
+                panic!("unexpected error: {err:?}");
+            }
+        }
+        mock_server_error.expect(len).assert_async().await;
+    }
+    Ok(())
+}
+
+#[tokio::test]
+async fn send_procedure() -> Result<(), Box<dyn std::error::Error>> {
+    let mut server = Server::new_async().await;
+    let mock_ok = server
+        .mock("POST", "/xrpc/test/ok")
+        .match_header("content-type", "application/json")
+        .match_body(Matcher::JsonString(r#"{"data": "foo"}"#.into()))
+        .with_status(200)
+        .with_header("content-type", "application/json")
+        .with_body(r#"{"data": "bar"}"#)
+        .create_async()
+        .await;
+    let mock_err = server
+        .mock("POST", "/xrpc/test/err")
+        .match_header("content-type", "application/json")
+        .match_body(Matcher::JsonString(r#"{"data": "foo"}"#.into()))
+        .with_status(400)
+        .with_body(r#"{"error": "Bad"}"#)
+        .create_async()
+        .await;
+    let mock_server_error = server
+        .mock("POST", "/xrpc/test/500")
+        .match_query(Matcher::Any)
+        .with_status(500)
+        .create_async()
+        .await;
+
+    async fn run(
+        base_uri: &str,
+        path: &str,
+    ) -> Vec<Result<Result<Output, atrium_xrpc::error::Error<Error>>, JoinError>> {
+        let handles = vec![
+            #[cfg(feature = "isahc")]
+            tokio::spawn(run_procedure(
+                crate::isahc::IsahcClientBuilder::new(base_uri)
+                    .client(
+                        isahc::HttpClient::builder()
+                            .build()
+                            .expect("client should be successfully built"),
+                    )
+                    .build(),
+                path.to_string(),
+            )),
+            #[cfg(feature = "reqwest-native")]
+            tokio::spawn(run_procedure(
+                crate::reqwest::ReqwestClientBuilder::new(base_uri)
+                    .client(
+                        reqwest::ClientBuilder::new()
+                            .use_native_tls()
+                            .build()
+                            .expect("client should be successfully built"),
+                    )
+                    .build(),
+                path.to_string(),
+            )),
+            #[cfg(feature = "reqwest-rustls")]
+            tokio::spawn(run_procedure(
+                crate::reqwest::ReqwestClientBuilder::new(base_uri)
+                    .client(
+                        reqwest::ClientBuilder::new()
+                            .use_rustls_tls()
+                            .build()
+                            .expect("client should be successfully built"),
+                    )
+                    .build(),
+                path.to_string(),
+            )),
+            #[cfg(feature = "surf")]
+            tokio::spawn(run_procedure(
+                crate::surf::SurfClient::new(
+                    base_uri,
+                    surf::Client::with_http_client(http_client::h1::H1Client::new()),
+                ),
+                path.to_string(),
+            )),
+        ];
+        join_all(handles).await
+    }
+
+    // Ok
+    {
+        let results = run(&server.url(), "test/ok").await;
+        let len = results.len();
+        for result in results {
+            let output = result?.expect("xrpc response should be ok");
+            assert_eq!(output.data, "bar");
+        }
+        mock_ok.expect(len).assert_async().await;
+    }
+    // Err (XrpcError)
+    {
+        let results = run(&server.url(), "test/err").await;
+        let len = results.len();
+        for result in results {
+            let err = result?.expect_err("xrpc response should be error");
+            if let atrium_xrpc::error::Error::XrpcResponse(e) = err {
+                assert_eq!(e.status, 400);
+                if let Some(atrium_xrpc::error::XrpcErrorKind::Custom(Error::Bad)) = e.error {
+                } else {
+                    panic!("unexpected error kind: {e:?}");
+                }
+            } else {
+                panic!("unexpected error: {err:?}");
+            }
+        }
+        mock_err.expect(len).assert_async().await;
+    }
+    // Err (server error)
+    {
+        let results = run(&server.url(), "test/500").await;
+        let len = results.len();
+        for result in results {
+            let err = result?.expect_err("xrpc response should be error");
+            if let atrium_xrpc::error::Error::XrpcResponse(e) = err {
+                assert_eq!(e.status, 500);
+                assert!(e.error.is_none());
+            } else {
+                panic!("unexpected error: {err:?}");
+            }
+        }
+        mock_server_error.expect(len).assert_async().await;
+    }
+    Ok(())
+}