From d1750f7fbb849ad820d00dd9d53e6fa4c21fa43f Mon Sep 17 00:00:00 2001 From: Yoshihiro Sugi Date: Fri, 10 Nov 2023 18:03:29 +0900 Subject: [PATCH] feat: Add xrpc-client package (#63) * Add xrpc-client implementation * Add README, doc tests and comments * Add workflows * Fix root Cargo.toml * Oops... * Add tests * Fix tests * Add tests * Use atrium-xrpc 0.5, rename methods * Add docs * Ready to publish --- .github/workflows/xrpc-client.yml | 28 +++ Cargo.toml | 1 + atrium-xrpc-client/Cargo.toml | 49 +++++ atrium-xrpc-client/README.md | 157 +++++++++++++++ atrium-xrpc-client/src/isahc.rs | 116 +++++++++++ atrium-xrpc-client/src/lib.rs | 18 ++ atrium-xrpc-client/src/reqwest.rs | 112 +++++++++++ atrium-xrpc-client/src/surf.rs | 90 +++++++++ atrium-xrpc-client/src/tests.rs | 315 ++++++++++++++++++++++++++++++ release-plz.toml | 5 + 10 files changed, 891 insertions(+) create mode 100644 .github/workflows/xrpc-client.yml create mode 100644 atrium-xrpc-client/Cargo.toml create mode 100644 atrium-xrpc-client/README.md create mode 100644 atrium-xrpc-client/src/isahc.rs create mode 100644 atrium-xrpc-client/src/lib.rs create mode 100644 atrium-xrpc-client/src/reqwest.rs create mode 100644 atrium-xrpc-client/src/surf.rs create mode 100644 atrium-xrpc-client/src/tests.rs 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 "] +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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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, +} + +impl IsahcClient { + /// Create a new [`IsahcClient`] using the default configuration. + pub fn new(base_uri: impl AsRef) -> Self { + IsahcClientBuilder::new(base_uri).build() + } +} + +/// A client builder, capable of creating custom [`IsahcClient`] instances. +pub struct IsahcClientBuilder { + base_uri: String, + client: Option, +} + +impl IsahcClientBuilder { + /// Create a new [`IsahcClientBuilder`] for building a custom client. + pub fn new(base_uri: impl AsRef) -> 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>, + ) -> Result>, Box> { + 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> { + let client = IsahcClient::new("http://localhost:8080"); + assert_eq!(client.base_uri(), "http://localhost:8080"); + Ok(()) + } + + #[test] + fn builder_without_client() -> Result<(), Box> { + let client = IsahcClientBuilder::new("http://localhost:8080").build(); + assert_eq!(client.base_uri(), "http://localhost:8080"); + Ok(()) + } + + #[test] + fn builder_with_client() -> Result<(), Box> { + 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, +} + +impl ReqwestClient { + /// Create a new [`ReqwestClient`] using the default configuration. + pub fn new(base_uri: impl AsRef) -> ReqwestClient { + ReqwestClientBuilder::new(base_uri).build() + } +} + +/// A client builder, capable of creating custom [`ReqwestClient`] instances. +pub struct ReqwestClientBuilder { + base_uri: String, + client: Option, +} + +impl ReqwestClientBuilder { + /// Create a new [`ReqwestClientBuilder`] for building a custom client. + pub fn new(base_uri: impl AsRef) -> 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>, + ) -> Result>, Box> { + 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> { + let client = ReqwestClient::new("http://localhost:8080"); + assert_eq!(client.base_uri(), "http://localhost:8080"); + Ok(()) + } + + #[test] + fn builder_without_client() -> Result<(), Box> { + let client = ReqwestClientBuilder::new("http://localhost:8080").build(); + assert_eq!(client.base_uri(), "http://localhost:8080"); + Ok(()) + } + + #[test] + fn builder_with_client() -> Result<(), Box> { + 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, +} + +impl SurfClient { + /// Create a new [`SurfClient`] using the passed [`surf::Client`]. + pub fn new(base_uri: impl AsRef, 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>, + ) -> Result>, Box> { + 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> { + 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/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> { + 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> { + 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> { + 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>, 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> { + 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>, 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(()) +} 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