From 2e70d350edb9068ebc6476446f0c5a735def07cf Mon Sep 17 00:00:00 2001 From: Mauro D Date: Fri, 9 Sep 2022 09:49:32 +0000 Subject: [PATCH] v0.1.0 --- CHANGELOG.md | 3 + README.md | 171 +++++++++++++++++++++++++++++++- examples/eventsource.rs | 62 ++++++++++++ examples/mailboxes.rs | 69 +++++++++++++ examples/messages.rs | 120 +++++++++++++++++++++++ examples/result_reference.rs | 101 +++++++++++++++++++ examples/websocket.rs | 86 ++++++++++++++++ src/lib.rs | 183 +++++++++++++++++++++++++++++++++-- 8 files changed, 786 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 examples/eventsource.rs create mode 100644 examples/mailboxes.rs create mode 100644 examples/messages.rs create mode 100644 examples/result_reference.rs create mode 100644 examples/websocket.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7eeb984 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +jmap-client 0.1.0 +================================ +- First release. diff --git a/README.md b/README.md index 6de1e49..0210326 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,169 @@ -JMAP Client for Rust -==== +# jmap-client + +[![crates.io](https://img.shields.io/crates/v/jmap-client)](https://crates.io/crates/jmap-client) +[![build](https://github.com/stalwartlabs/jmap-client/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/jmap-client/actions/workflows/rust.yml) +[![docs.rs](https://img.shields.io/docsrs/jmap-client)](https://docs.rs/jmap-client) +[![crates.io](https://img.shields.io/crates/l/jmap-client)](http://www.apache.org/licenses/LICENSE-2.0) + +_jmap-client_ is a **JSON Meta Application Protocol (JMAP) library** written in Rust. The library is a full implementation of the JMAP RFCs including: + +- JMAP Core ([RFC 8620](https://datatracker.ietf.org/doc/html/rfc8620)) +- JMAP for Mail ([RFC 8621](https://datatracker.ietf.org/doc/html/rfc8621)) +- JMAP over WebSocket ([RFC 8887](https://datatracker.ietf.org/doc/html/rfc8887)). + +Features: + +- Async and blocking support (use the cargo feature ``blocking`` to enable blocking). +- WebSocket async streams (use the cargo feature ``websockets`` to enable JMAP over WebSocket). +- EventSource async streams. +- Helper functions to reduce boilerplate code and quickly build JMAP requests. +- Fast parsing and encoding of JMAP requests. + +## Usage Example + +```rust + // Connect to the JMAP server using Basic authentication. + // (just for demonstration purposes, Bearer tokens should be used instead) + let client = Client::new() + .credentials(("john@example.org", "secret")) + .connect("https://jmap.example.org") + .await + .unwrap(); + + // Create a mailbox. + let mailbox_id = client + .mailbox_create("My Mailbox", None::, Role::None) + .await + .unwrap() + .take_id(); + + // Import a message into the mailbox. + client + .email_import( + b"From: john@example.org\nSubject: test\n\n test".to_vec(), + [&mailbox_id], + ["$draft"].into(), + None, + ) + .await + .unwrap(); + + // Obtain all e-mail ids matching a filter. + let email_id = client + .email_query( + Filter::and([ + email::query::Filter::subject("test"), + email::query::Filter::in_mailbox(&mailbox_id), + email::query::Filter::has_keyword("$draft"), + ]) + .into(), + [email::query::Comparator::from()].into(), + ) + .await + .unwrap() + .take_ids() + .pop() + .unwrap(); + + // Fetch an e-mail message. + let email = client + .email_get( + &email_id, + [Property::Subject, Property::Preview, Property::Keywords].into(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(email.preview().unwrap(), "test"); + assert_eq!(email.subject().unwrap(), "test"); + assert_eq!(email.keywords(), ["$draft"]); + + // Fetch only the updated properties of all mailboxes that changed + // since a state. + let mut request = client.build(); + let changes_request = request.changes_mailbox("n").max_changes(0); + let properties_ref = changes_request.updated_properties_reference(); + let updated_ref = changes_request.updated_reference(); + request + .get_mailbox() + .ids_ref(updated_ref) + .properties_ref(properties_ref); + for mailbox in request + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_get_mailbox() + .unwrap() + .take_list() + { + println!("Changed mailbox: {:#?}", mailbox); + } + + // Delete the mailbox including any messages + client.mailbox_destroy(&mailbox_id, true).await.unwrap(); + + // Open an EventSource connection with the JMAP server. + let mut stream = client + .event_source( + [ + TypeState::Email, + TypeState::EmailDelivery, + TypeState::Mailbox, + TypeState::EmailSubmission, + TypeState::Identity, + ] + .into(), + false, + 60.into(), + None, + ) + .await + .unwrap(); + + // Consume events received over EventSource. + while let Some(event) = stream.next().await { + let changes = event.unwrap(); + println!("-> Change id: {:?}", changes.id()); + for account_id in changes.changed_accounts() { + println!(" Account {} has changes:", account_id); + if let Some(account_changes) = changes.changes(account_id) { + for (type_state, state_id) in account_changes { + println!(" Type {:?} has a new state {}.", type_state, state_id); + } + } + } + } +``` + +More examples available under the [examples](examples) directory. + +## Testing + +To run the testsuite: + +```bash + $ cargo test --all-features +``` + +## Conformed RFCs + +- [RFC 8620 - The JSON Meta Application Protocol (JMAP)](https://datatracker.ietf.org/doc/html/rfc8620) +- [RFC 8621 - The JSON Meta Application Protocol (JMAP) for Mail](https://datatracker.ietf.org/doc/html/rfc8621) +- [RFC 8887 - A JSON Meta Application Protocol (JMAP) Subprotocol for WebSocket](https://datatracker.ietf.org/doc/html/rfc8887) + +## License + +Licensed under either of + + * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) + * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. + +## Copyright + +Copyright (C) 2022, Stalwart Labs Ltd. -(documentation is a work in progress) diff --git a/examples/eventsource.rs b/examples/eventsource.rs new file mode 100644 index 0000000..dfd09b5 --- /dev/null +++ b/examples/eventsource.rs @@ -0,0 +1,62 @@ +/* + * Copyright Stalwart Labs Ltd. See the COPYING + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * , at your + * option. This file may not be copied, modified, or distributed + * except according to those terms. + */ + +use futures_util::StreamExt; +use jmap_client::{client::Client, TypeState}; + +async fn event_source() { + // Connect to the JMAP server using Basic authentication + let client = Client::new() + .credentials(("john@example.org", "secret")) + .connect("https://jmap.example.org") + .await + .unwrap(); + + // Open EventSource connection + let mut stream = client + .event_source( + [ + TypeState::Email, + TypeState::EmailDelivery, + TypeState::Mailbox, + TypeState::EmailSubmission, + TypeState::Identity, + ] + .into(), + false, + 60.into(), + None, + ) + .await + .unwrap(); + + // Consume events + while let Some(event) = stream.next().await { + let changes = event.unwrap(); + println!("-> Change id: {:?}", changes.id()); + for account_id in changes.changed_accounts() { + println!(" Account {} has changes:", account_id); + if let Some(account_changes) = changes.changes(account_id) { + for (type_state, state_id) in account_changes { + println!(" Type {:?} has a new state {}.", type_state, state_id); + } + } + } + } +} + +fn main() { + let _c = event_source(); +} diff --git a/examples/mailboxes.rs b/examples/mailboxes.rs new file mode 100644 index 0000000..4890d43 --- /dev/null +++ b/examples/mailboxes.rs @@ -0,0 +1,69 @@ +/* + * Copyright Stalwart Labs Ltd. See the COPYING + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * , at your + * option. This file may not be copied, modified, or distributed + * except according to those terms. + */ + +use jmap_client::{ + client::Client, + mailbox::{query::Filter, Role}, +}; + +async fn mailboxes() { + // Connect to the JMAP server using Basic authentication + let client = Client::new() + .credentials(("john@example.org", "secret")) + .connect("https://jmap.example.org") + .await + .unwrap(); + + // Create a mailbox + let mailbox_id = client + .mailbox_create("My Mailbox", None::, Role::None) + .await + .unwrap() + .take_id(); + + // Rename a mailbox + client + .mailbox_rename(&mailbox_id, "My Renamed Mailbox") + .await + .unwrap(); + + // Query mailboxes to obtain Inbox's id + let inbox_id = client + .mailbox_query(Filter::role(Role::Inbox).into(), None::>) + .await + .unwrap() + .take_ids() + .pop() + .unwrap(); + + // Print Inbox's details + println!( + "{:?}", + client.mailbox_get(&inbox_id, None::>).await.unwrap() + ); + + // Move the newly created mailbox under Inbox + client + .mailbox_move(&mailbox_id, inbox_id.into()) + .await + .unwrap(); + + // Delete the mailbox including any messages + client.mailbox_destroy(&mailbox_id, true).await.unwrap(); +} + +fn main() { + let _c = mailboxes(); +} diff --git a/examples/messages.rs b/examples/messages.rs new file mode 100644 index 0000000..5a2bd2b --- /dev/null +++ b/examples/messages.rs @@ -0,0 +1,120 @@ +/* + * Copyright Stalwart Labs Ltd. See the COPYING + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * , at your + * option. This file may not be copied, modified, or distributed + * except according to those terms. + */ + +use jmap_client::{ + client::Client, + core::query::Filter, + email::{self, Property}, + mailbox::{self, Role}, +}; + +const TEST_MESSAGE: &[u8; 90] = br#"From: john@example.org +To: jane@example.org +Subject: Testing JMAP client + +This is a test. +"#; + +async fn messages() { + // Connect to the JMAP server using Basic authentication + let client = Client::new() + .credentials(("john@example.org", "secret")) + .connect("https://jmap.example.org") + .await + .unwrap(); + + // Query mailboxes to obtain Inbox and Trash folder id + let inbox_id = client + .mailbox_query( + mailbox::query::Filter::role(Role::Inbox).into(), + None::>, + ) + .await + .unwrap() + .take_ids() + .pop() + .unwrap(); + let trash_id = client + .mailbox_query( + mailbox::query::Filter::role(Role::Trash).into(), + None::>, + ) + .await + .unwrap() + .take_ids() + .pop() + .unwrap(); + + // Import message into inbox + client + .email_import(TEST_MESSAGE.to_vec(), [&inbox_id], ["$draft"].into(), None) + .await + .unwrap(); + + // Query mailbox + let email_id = client + .email_query( + Filter::and([ + email::query::Filter::subject("test"), + email::query::Filter::in_mailbox(&inbox_id), + email::query::Filter::has_keyword("$draft"), + ]) + .into(), + [email::query::Comparator::from()].into(), + ) + .await + .unwrap() + .take_ids() + .pop() + .unwrap(); + + // Fetch message + let email = client + .email_get( + &email_id, + [Property::Subject, Property::Preview, Property::Keywords].into(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(email.preview().unwrap(), "This is a test."); + assert_eq!(email.subject().unwrap(), "Testing JMAP client"); + assert_eq!(email.keywords(), ["$draft"]); + + // Remove the $draft keyword + client + .email_set_keyword(&email_id, "$draft", false) + .await + .unwrap(); + + // Replace all keywords + client + .email_set_keywords(&email_id, ["$seen", "$important"]) + .await + .unwrap(); + + // Move the message to the Trash folder + client + .email_set_mailboxes(&email_id, [&trash_id]) + .await + .unwrap(); + + // Destroy the e-mail + client.email_destroy(&email_id).await.unwrap(); +} + +fn main() { + let _c = messages(); +} diff --git a/examples/result_reference.rs b/examples/result_reference.rs new file mode 100644 index 0000000..75aa4ae --- /dev/null +++ b/examples/result_reference.rs @@ -0,0 +1,101 @@ +/* + * Copyright Stalwart Labs Ltd. See the COPYING + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * , at your + * option. This file may not be copied, modified, or distributed + * except according to those terms. + */ + +use jmap_client::{client::Client, core::query, email, mailbox}; + +async fn result_reference() { + // Connect to the JMAP server using Basic authentication + let client = Client::new() + .credentials(("john@example.org", "secret")) + .connect("https://jmap.example.org") + .await + .unwrap(); + + // Delete e-mails matching a filter + let mut request = client.build(); + let result_ref = request + .query_email() + .filter(query::Filter::and([ + email::query::Filter::has_keyword("$draft"), + email::query::Filter::from("bill"), + ])) + .result_reference(); + request.set_email().destroy_ref(result_ref); + let _destroyed_ids = request + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_set_email() + .unwrap() + .take_destroyed_ids(); + + // Fetch mailboxes matching a filter + let mut request = client.build(); + let query_result = request + .query_mailbox() + .filter(query::Filter::and([ + mailbox::query::Filter::has_any_role(false), + mailbox::query::Filter::is_subscribed(true), + ])) + .result_reference(); + request.get_mailbox().ids_ref(query_result).properties([ + mailbox::Property::Id, + mailbox::Property::Name, + mailbox::Property::ParentId, + mailbox::Property::TotalEmails, + mailbox::Property::UnreadEmails, + ]); + let _mailboxes = request + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_get_mailbox() + .unwrap() + .take_list(); + + // Fetch only the updated properties of all mailboxes that changed + // since a state. + let mut request = client.build(); + let changes_request = request.changes_mailbox("n").max_changes(0); + let properties_ref = changes_request.updated_properties_reference(); + let updated_ref = changes_request.updated_reference(); + request + .get_mailbox() + .ids_ref(updated_ref) + .properties_ref(properties_ref); + for mailbox in request + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_get_mailbox() + .unwrap() + .take_list() + { + println!("Changed mailbox: {:#?}", mailbox); + } +} + +fn main() { + let _c = result_reference(); +} diff --git a/examples/websocket.rs b/examples/websocket.rs new file mode 100644 index 0000000..c6b1cb9 --- /dev/null +++ b/examples/websocket.rs @@ -0,0 +1,86 @@ +/* + * Copyright Stalwart Labs Ltd. See the COPYING + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * file at the top-level directory of this distribution. + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * , at your + * option. This file may not be copied, modified, or distributed + * except according to those terms. + */ + +use futures_util::StreamExt; +use jmap_client::{client::Client, client_ws::WebSocketMessage, core::set::SetObject}; +use tokio::sync::mpsc; + +// Make sure the "websockets" feature is enabled! +async fn websocket() { + // Connect to the JMAP server using Basic authentication + let client = Client::new() + .credentials(("john@example.org", "secret")) + .connect("https://jmap.example.org") + .await + .unwrap(); + + // Connect to the WebSocket endpoint + let mut ws_stream = client.connect_ws().await.unwrap(); + + // Read WS messages on a separate thread + let (stream_tx, mut stream_rx) = mpsc::channel::(100); + tokio::spawn(async move { + while let Some(change) = ws_stream.next().await { + stream_tx.send(change.unwrap()).await.unwrap(); + } + }); + + // Create a mailbox over WS + let mut request = client.build(); + let create_id = request + .set_mailbox() + .create() + .name("WebSocket Test") + .create_id() + .unwrap(); + let request_id = request.send_ws().await.unwrap(); + + // Read response from WS stream + let mailbox_id = if let Some(WebSocketMessage::Response(mut response)) = stream_rx.recv().await + { + assert_eq!(request_id, response.request_id().unwrap()); + response + .pop_method_response() + .unwrap() + .unwrap_set_mailbox() + .unwrap() + .created(&create_id) + .unwrap() + .take_id() + } else { + unreachable!() + }; + + // Enable push notifications over WS + client + .enable_push_ws(None::>, None::<&str>) + .await + .unwrap(); + + // Make changes over standard HTTP and expect a push notification via WS + client + .mailbox_update_sort_order(&mailbox_id, 1) + .await + .unwrap(); + if let Some(WebSocketMessage::StateChange(changes)) = stream_rx.recv().await { + println!("Received changes: {:?}", changes); + } else { + unreachable!() + } +} + +fn main() { + let _c = websocket(); +} diff --git a/src/lib.rs b/src/lib.rs index 759daf4..03fdb0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,13 +9,177 @@ * except according to those terms. */ -use crate::core::error::MethodError; -use crate::core::error::ProblemDetails; -use crate::core::set::SetError; -use ahash::AHashMap; -use serde::{Deserialize, Serialize}; -use std::fmt::Display; +//! # jmap-client +//! +//! [![crates.io](https://img.shields.io/crates/v/jmap-client)](https://crates.io/crates/jmap-client) +//! [![build](https://github.com/stalwartlabs/jmap-client/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/jmap-client/actions/workflows/rust.yml) +//! [![docs.rs](https://img.shields.io/docsrs/jmap-client)](https://docs.rs/jmap-client) +//! [![crates.io](https://img.shields.io/crates/l/jmap-client)](http://www.apache.org/licenses/LICENSE-2.0) +//! +//! _jmap-client_ is a **JSON Meta Application Protocol (JMAP) library** written in Rust. The library is a full implementation of the JMAP RFCs including: +//! +//! - JMAP Core ([RFC 8620](https://datatracker.ietf.org/doc/html/rfc8620)) +//! - JMAP for Mail ([RFC 8621](https://datatracker.ietf.org/doc/html/rfc8621)) +//! - JMAP over WebSocket ([RFC 8887](https://datatracker.ietf.org/doc/html/rfc8887)). +//! +//! Features: +//! +//! - Async and blocking support (use the cargo feature ``blocking`` to enable blocking). +//! - WebSocket async streams (use the cargo feature ``websockets`` to enable JMAP over WebSocket). +//! - EventSource async streams. +//! - Helper functions to reduce boilerplate code and quickly build JMAP requests. +//! - Fast parsing and encoding of JMAP requests. +//! +//! ## Usage Example +//! +//! ```rust +//! // Connect to the JMAP server using Basic authentication. +//! // (just for demonstration purposes, Bearer tokens should be used instead) +//! let client = Client::new() +//! .credentials(("john@example.org", "secret")) +//! .connect("https://jmap.example.org") +//! .await +//! .unwrap(); +//! +//! // Create a mailbox. +//! let mailbox_id = client +//! .mailbox_create("My Mailbox", None::, Role::None) +//! .await +//! .unwrap() +//! .take_id(); +//! +//! // Import a message into the mailbox. +//! client +//! .email_import( +//! b"From: john@example.org\nSubject: test\n\n test".to_vec(), +//! [&mailbox_id], +//! ["$draft"].into(), +//! None, +//! ) +//! .await +//! .unwrap(); +//! +//! // Obtain all e-mail ids matching a filter. +//! let email_id = client +//! .email_query( +//! Filter::and([ +//! email::query::Filter::subject("test"), +//! email::query::Filter::in_mailbox(&mailbox_id), +//! email::query::Filter::has_keyword("$draft"), +//! ]) +//! .into(), +//! [email::query::Comparator::from()].into(), +//! ) +//! .await +//! .unwrap() +//! .take_ids() +//! .pop() +//! .unwrap(); +//! +//! // Fetch an e-mail message. +//! let email = client +//! .email_get( +//! &email_id, +//! [Property::Subject, Property::Preview, Property::Keywords].into(), +//! ) +//! .await +//! .unwrap() +//! .unwrap(); +//! assert_eq!(email.preview().unwrap(), "test"); +//! assert_eq!(email.subject().unwrap(), "test"); +//! assert_eq!(email.keywords(), ["$draft"]); +//! +//! // Fetch only the updated properties of all mailboxes that changed +//! // since a state. +//! let mut request = client.build(); +//! let changes_request = request.changes_mailbox("n").max_changes(0); +//! let properties_ref = changes_request.updated_properties_reference(); +//! let updated_ref = changes_request.updated_reference(); +//! request +//! .get_mailbox() +//! .ids_ref(updated_ref) +//! .properties_ref(properties_ref); +//! for mailbox in request +//! .send() +//! .await +//! .unwrap() +//! .unwrap_method_responses() +//! .pop() +//! .unwrap() +//! .unwrap_get_mailbox() +//! .unwrap() +//! .take_list() +//! { +//! println!("Changed mailbox: {:#?}", mailbox); +//! } +//! +//! // Delete the mailbox including any messages +//! client.mailbox_destroy(&mailbox_id, true).await.unwrap(); +//! +//! // Open an EventSource connection with the JMAP server. +//! let mut stream = client +//! .event_source( +//! [ +//! TypeState::Email, +//! TypeState::EmailDelivery, +//! TypeState::Mailbox, +//! TypeState::EmailSubmission, +//! TypeState::Identity, +//! ] +//! .into(), +//! false, +//! 60.into(), +//! None, +//! ) +//! .await +//! .unwrap(); +//! +//! // Consume events received over EventSource. +//! while let Some(event) = stream.next().await { +//! let changes = event.unwrap(); +//! println!("-> Change id: {:?}", changes.id()); +//! for account_id in changes.changed_accounts() { +//! println!(" Account {} has changes:", account_id); +//! if let Some(account_changes) = changes.changes(account_id) { +//! for (type_state, state_id) in account_changes { +//! println!(" Type {:?} has a new state {}.", type_state, state_id); +//! } +//! } +//! } +//! } +//! ``` +//! +//! More examples available under the [examples](examples) directory. +//! +//! ## Testing +//! +//! To run the testsuite: +//! +//! ```bash +//! $ cargo test --all-features +//! ``` +//! +//! ## Conformed RFCs +//! +//! - [RFC 8620 - The JSON Meta Application Protocol (JMAP)](https://datatracker.ietf.org/doc/html/rfc8620) +//! - [RFC 8621 - The JSON Meta Application Protocol (JMAP) for Mail](https://datatracker.ietf.org/doc/html/rfc8621) +//! - [RFC 8887 - A JSON Meta Application Protocol (JMAP) Subprotocol for WebSocket](https://datatracker.ietf.org/doc/html/rfc8887) +//! +//! ## License +//! +//! Licensed under either of +//! +//! * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) +//! * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) +//! +//! at your option. +//! +//! ## Copyright +//! +//! Copyright (C) 2022, Stalwart Labs Ltd. +//! +#[forbid(unsafe_code)] pub mod blob; pub mod client; pub mod core; @@ -30,6 +194,13 @@ pub mod push_subscription; pub mod thread; pub mod vacation_response; +use crate::core::error::MethodError; +use crate::core::error::ProblemDetails; +use crate::core::set::SetError; +use ahash::AHashMap; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + #[cfg(feature = "websockets")] pub mod client_ws;