From 55af000481100c41bc2e5b62833f46e62110185a Mon Sep 17 00:00:00 2001 From: LJ Date: Sun, 28 Jul 2024 22:30:30 +0300 Subject: [PATCH] Work on SignalR client --- .config/extra.dic | 7 +- .vscode/settings.json | 3 +- Cargo.toml | 23 +++- README.md | 4 +- src/api_client/http.rs | 227 +++++++++++++++++++++++++++++++++ src/api_client/mod.rs | 256 ++++++-------------------------------- src/api_client/signalr.rs | 160 ++++++++++++++++++++++++ src/lib.rs | 5 +- src/query/contact.rs | 2 +- src/query/group.rs | 2 +- src/query/message.rs | 4 +- src/query/mod.rs | 11 ++ src/query/session.rs | 4 +- src/query/stats.rs | 6 +- src/query/testing.rs | 4 +- src/query/user.rs | 4 +- src/query/user_session.rs | 4 +- src/signalr/mod.rs | 82 ++++++++++++ tests/common/mod.rs | 8 ++ tests/signalr.rs | 27 ++++ 20 files changed, 602 insertions(+), 241 deletions(-) create mode 100644 src/api_client/http.rs create mode 100644 src/api_client/signalr.rs create mode 100644 src/signalr/mod.rs create mode 100644 tests/signalr.rs diff --git a/.config/extra.dic b/.config/extra.dic index b584b6d..7a7c027 100644 --- a/.config/extra.dic +++ b/.config/extra.dic @@ -1,4 +1,4 @@ -40 +50 Resonite Resonite's ProtoFlux @@ -34,3 +34,8 @@ serializer Deserializes Versioning usernames +WS +async +mutex +RPC +HTTPS diff --git a/.vscode/settings.json b/.vscode/settings.json index 2decb06..b5bc2f1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "rust-analyzer.cargo.features": "all" + "rust-analyzer.cargo.features": "all", + "editor.formatOnSave": true } diff --git a/Cargo.toml b/Cargo.toml index d338c5b..6dbcae3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "resonite" -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "MPL-2.0" authors = ["ljoonal"] @@ -29,6 +29,7 @@ bench = false [features] default = [] http_client = ["tokio", "governor", "reqwest", "racal/reqwest", "async-trait"] +signalr_client = ["http_client", "tokio", "ezsockets", "tokio-stream", "http", "tokio-tungstenite", "async-trait"] rand_util = ["nanorand"] [dependencies] @@ -43,21 +44,33 @@ strum = { version = "0.26.3", features = ["derive"] } # API client specifics racal = "0.4.0" #racal = { path = "../racal", features = ["reqwest"] } -governor = { version = "0.6.3", optional = true } -tokio = { version = "1.39.1", optional = true } -async-trait = { version = "0.1.81", optional = true } nanorand = { version = "0.7.0", optional = true } +governor = { version = "0.6.3", optional = true } + +tokio = { version = "1.39.2", optional = true, features = ["macros"]} +tokio-stream = { version = "0.1.15", optional = true} +http = { version = "1.1.0", optional = true } +async-trait = { version = "0.1.81", optional = true } +# Required to be defined by us since ezsockets doesn't expose a TLS feature +tokio-tungstenite = {version = "0.23.1", optional= true, default-features = false, features = ["rustls-tls-webpki-roots"] } + [dependencies.reqwest] optional = true version = "0.12.5" default-features = false features = ["json", "rustls-tls"] +[dependencies.ezsockets] +optional = true +version = "0.6.2" +default-features = false +features = ["client", "native_client"] + [dev-dependencies] tokio-test = "0.4.4" -tokio = { version = "1.39.1", features = ["rt", "macros"] } +tokio = { version = "1.39.2", features = ["rt", "macros"] } [package.metadata.docs.rs] all-features = true diff --git a/README.md b/README.md index 7384bc5..2348f01 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ [![Crates.io](https://img.shields.io/crates/v/resonite.svg)](https://crates.io/crates/resonite) [![Docs](https://docs.rs/resonite/badge.svg)](https://docs.rs/crate/resonite/) -WIP Rust models of [Resonite's](https://resonite.com) API. +Rust models of [Resonite's](https://resonite.com) API. Any official documentation of Resonite' API is lacking, and the API is still changing too. So this crate can't guarantee correctness. -This crate provides an example API client with the optional `api_client` feature. +This crate provides an example API client with the optional `http_client` & `signalr_client` features. ## Testing diff --git a/src/api_client/http.rs b/src/api_client/http.rs new file mode 100644 index 0000000..f105be4 --- /dev/null +++ b/src/api_client/http.rs @@ -0,0 +1,227 @@ +use std::num::NonZeroU32; + +use governor::{ + clock::DefaultClock, + middleware::NoOpMiddleware, + state::{InMemoryState, NotKeyed}, + Quota, + RateLimiter, +}; +pub use racal::reqwest::ApiClient; +use reqwest::{ + header::{HeaderMap, HeaderValue}, + Client, + RequestBuilder, +}; +use serde::{Deserialize, Serialize}; + +use super::ApiError; +use crate::query::{Authenticating, Authentication, NoAuthentication}; + +type NormalRateLimiter = + RateLimiter; + +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] +/// Data needed to actually request an user session. +/// +/// Mixes headers and actual body data together, not an actual Resonite model. +pub struct UserSessionQueryWithHeaders { + /// The actual body of the request + pub body: crate::query::UserSession, + #[serde(flatten)] + /// Headers & so on needed for authentication requests + pub data: Authenticating, +} + +#[must_use] +fn http_rate_limiter() -> NormalRateLimiter { + // ~5 seconds per request sustained over one minute, allowing up to a request + // per second in bursts. + RateLimiter::direct( + Quota::per_minute(NonZeroU32::try_from(12).unwrap()) + .allow_burst(NonZeroU32::try_from(5).unwrap()), + ) +} + +/// The main API client without authentication +pub struct UnauthenticatedResonite { + user_agent: String, + http: Client, + rate_limiter: NormalRateLimiter, +} + +#[async_trait::async_trait] +impl ApiClient for UnauthenticatedResonite { + fn state(&self) -> &NoAuthentication { &NoAuthentication {} } + + fn client(&self) -> &reqwest::Client { &self.http } + + async fn before_request( + &self, req: RequestBuilder, + ) -> Result { + self.rate_limiter.until_ready().await; + Ok(req) + } +} + +/// The main API client that's in the process of authentication +/// +/// Created with a tuple of the unauthenticated client & authentication, +/// and can always be downgraded into an unauthenticated client. +pub struct AuthenticatingResonite { + base: UnauthenticatedResonite, + data: Authenticating, +} + +impl From<(UnauthenticatedResonite, Authenticating)> + for AuthenticatingResonite +{ + fn from(value: (UnauthenticatedResonite, Authenticating)) -> Self { + Self { base: value.0, data: value.1 } + } +} + +impl From for UnauthenticatedResonite { + fn from(value: AuthenticatingResonite) -> Self { value.base } +} + +#[async_trait::async_trait] +impl ApiClient for AuthenticatingResonite { + fn state(&self) -> &Authenticating { &self.data } + + fn client(&self) -> &reqwest::Client { &self.base.http } + + async fn before_request( + &self, mut req: RequestBuilder, + ) -> Result { + self.base.rate_limiter.until_ready().await; + req = req.header("UID", &self.data.unique_machine_identifier); + if let Some(second_factor_token) = &self.data.second_factor { + req = req.header("TOTP", second_factor_token); + } + + Ok(dbg!(req)) + } +} + +/// The main API client with authentication +pub struct AuthenticatedResonite { + user_agent: String, + http: Client, + rate_limiter: NormalRateLimiter, + auth: Authentication, +} + +#[async_trait::async_trait] +impl ApiClient for AuthenticatedResonite { + fn state(&self) -> &Authentication { &self.auth } + + fn client(&self) -> &reqwest::Client { &self.http } + + async fn before_request( + &self, req: RequestBuilder, + ) -> Result { + self.rate_limiter.until_ready().await; + Ok(req) + } +} + +impl AuthenticatedResonite { + /// Creates an API client + fn http_client( + user_agent: &str, auth: &Authentication, + ) -> Result { + use serde::ser::Error; + + let builder = Client::builder(); + let mut headers = HeaderMap::new(); + + let (header_name, header_value) = auth.to_header(); + + headers.insert( + header_name, + header_value.parse().map_err(|_| { + serde_json::Error::custom("Couldn't turn auth into a header") + })?, + ); + + Ok(builder.user_agent(user_agent).default_headers(headers).build()?) + } + + /// Removes authentication to the API client + /// + /// # Errors + /// + /// If deserializing user agent fails. + pub fn downgrade(self) -> Result { + Ok(UnauthenticatedResonite { + http: UnauthenticatedResonite::http_client(&self.user_agent)?, + rate_limiter: self.rate_limiter, + user_agent: self.user_agent, + }) + } + + /// Creates a new authenticated Resonite API client + /// + /// # Errors + /// + /// If deserializing user agent into a header fails + pub fn new( + user_agent: String, auth: impl Into + Send, + ) -> Result { + let auth = auth.into(); + Ok(Self { + http: Self::http_client(&user_agent, &auth)?, + rate_limiter: http_rate_limiter(), + user_agent, + auth, + }) + } +} + +impl UnauthenticatedResonite { + /// Creates an unauthenticated API client + fn http_client(user_agent: &str) -> Result { + let mut default_headers = HeaderMap::new(); + default_headers.insert( + reqwest::header::ACCEPT, + HeaderValue::from_static("application/json"), + ); + Ok( + Client::builder() + .user_agent(user_agent) + .default_headers(default_headers) + .build()?, + ) + } + + /// Adds authentication to the API client + /// + /// # Errors + /// + /// If deserializing user agent or authentication fails. + pub fn upgrade( + self, auth: impl Into + Send, + ) -> Result { + let auth = auth.into(); + Ok(AuthenticatedResonite { + http: AuthenticatedResonite::http_client(&self.user_agent, &auth)?, + rate_limiter: self.rate_limiter, + user_agent: self.user_agent, + auth, + }) + } + + /// Creates a new Resonite API client + /// + /// # Errors + /// + /// If deserializing user agent into a header fails + pub fn new(user_agent: String) -> Result { + Ok(Self { + http: Self::http_client(&user_agent)?, + rate_limiter: http_rate_limiter(), + user_agent, + }) + } +} diff --git a/src/api_client/mod.rs b/src/api_client/mod.rs index 30dc8f4..aece1cf 100644 --- a/src/api_client/mod.rs +++ b/src/api_client/mod.rs @@ -1,4 +1,5 @@ -//! An optional API client feature using `reqwest` +//! An optional API client features using `reqwest` for the HTTP parts, +//! and `signalrs-client-custom-auth` for [`SignalR`](https://dotnet.microsoft.com/en-us/apps/aspnet/signalr). //! //! Besides using this, you could instead easily implement your own client using //! a different HTTP library with the [`racal::Queryable`](racal::Queryable) @@ -23,229 +24,52 @@ //! //! > Requires the `Authorization` header in addition to the rate limiting. -use std::num::NonZeroU32; - -use governor::{ - clock::DefaultClock, - middleware::NoOpMiddleware, - state::{InMemoryState, NotKeyed}, - Quota, - RateLimiter, -}; -pub use racal::reqwest::{ApiClient, ApiError}; -use reqwest::{ - header::{HeaderMap, HeaderValue}, - Client, - RequestBuilder, -}; -use serde::{Deserialize, Serialize}; - -use crate::query::{Authenticating, Authentication, NoAuthentication}; - -type NormalRateLimiter = - RateLimiter; - -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] -/// Data needed to actually request an user session. -/// -/// Mixes headers and actual body data together, not an actual Resonite model. -pub struct UserSessionQueryWithHeaders { - /// The actual body of the request - pub body: crate::query::UserSession, - #[serde(flatten)] - /// Headers & so on needed for authentication requests - pub data: Authenticating, -} - -#[must_use] -fn http_rate_limiter() -> NormalRateLimiter { - // ~5 seconds per request sustained over one minute, allowing up to a request - // per second in bursts. - RateLimiter::direct( - Quota::per_minute(NonZeroU32::try_from(12).unwrap()) - .allow_burst(NonZeroU32::try_from(5).unwrap()), - ) +#[cfg(feature = "http_client")] +mod http; +#[cfg(feature = "http_client")] +pub use http::*; + +#[cfg(feature = "signalr_client")] +mod signalr; +#[cfg(feature = "signalr_client")] +pub use signalr::*; + +/// An error that may happen with an API query +#[derive(Debug)] +pub enum ApiError { + /// An error happened with serialization + Serde(serde_json::Error), + /// An error happened with the HTTPS request + #[cfg(feature = "http_client")] + Http(reqwest::Error), + /// An error happened with the WS connection + #[cfg(feature = "signalr_client")] + WebSocket(ezsockets::Error), + /// An error happened with sending `SignalR` data + #[cfg(feature = "signalr_client")] + Other(String), } -/// The main API client without authentication -pub struct UnauthenticatedResonite { - user_agent: String, - http: Client, - rate_limiter: NormalRateLimiter, -} - -#[async_trait::async_trait] -impl ApiClient for UnauthenticatedResonite { - fn state(&self) -> &NoAuthentication { &NoAuthentication {} } - - fn client(&self) -> &reqwest::Client { &self.http } - - async fn before_request( - &self, req: RequestBuilder, - ) -> Result { - self.rate_limiter.until_ready().await; - Ok(req) - } +impl From for ApiError { + fn from(err: serde_json::Error) -> Self { Self::Serde(err) } } -/// The main API client that's in the process of authentication -/// -/// Created with a tuple of the unauthenticated client & authentication, -/// and can always be downgraded into an unauthenticated client. -pub struct AuthenticatingResonite { - base: UnauthenticatedResonite, - data: Authenticating, +#[cfg(feature = "http_client")] +impl From for ApiError { + fn from(err: reqwest::Error) -> Self { Self::Http(err) } } -impl From<(UnauthenticatedResonite, Authenticating)> - for AuthenticatingResonite -{ - fn from(value: (UnauthenticatedResonite, Authenticating)) -> Self { - Self { base: value.0, data: value.1 } - } -} - -impl From for UnauthenticatedResonite { - fn from(value: AuthenticatingResonite) -> Self { value.base } -} - -#[async_trait::async_trait] -impl ApiClient for AuthenticatingResonite { - fn state(&self) -> &Authenticating { &self.data } - - fn client(&self) -> &reqwest::Client { &self.base.http } - - async fn before_request( - &self, mut req: RequestBuilder, - ) -> Result { - self.base.rate_limiter.until_ready().await; - req = req.header("UID", &self.data.unique_machine_identifier); - if let Some(second_factor_token) = &self.data.second_factor { - req = req.header("TOTP", second_factor_token); +#[cfg(feature = "http_client")] +impl From for ApiError { + fn from(err: racal::reqwest::ApiError) -> Self { + match err { + racal::reqwest::ApiError::Reqwest(e) => Self::Http(e), + racal::reqwest::ApiError::Serde(e) => Self::Serde(e), } - - Ok(dbg!(req)) } } -/// The main API client with authentication -pub struct AuthenticatedResonite { - user_agent: String, - http: Client, - rate_limiter: NormalRateLimiter, - auth: Authentication, -} - -#[async_trait::async_trait] -impl ApiClient for AuthenticatedResonite { - fn state(&self) -> &Authentication { &self.auth } - - fn client(&self) -> &reqwest::Client { &self.http } - - async fn before_request( - &self, req: RequestBuilder, - ) -> Result { - self.rate_limiter.until_ready().await; - Ok(req) - } -} - -impl AuthenticatedResonite { - /// Creates an API client - fn http_client( - user_agent: &str, auth: &Authentication, - ) -> Result { - use serde::ser::Error; - - let builder = Client::builder(); - let mut headers = HeaderMap::new(); - - headers.insert( - "Authorization", - ("res ".to_owned() + auth.user_id.as_ref() + ":" + &auth.token) - .parse() - .map_err(|_| { - serde_json::Error::custom("Couldn't turn auth into a header") - })?, - ); - - Ok(builder.user_agent(user_agent).default_headers(headers).build()?) - } - - /// Removes authentication to the API client - /// - /// # Errors - /// - /// If deserializing user agent fails. - pub fn downgrade(self) -> Result { - Ok(UnauthenticatedResonite { - http: UnauthenticatedResonite::http_client(&self.user_agent)?, - rate_limiter: self.rate_limiter, - user_agent: self.user_agent, - }) - } - - /// Creates a new authenticated Resonite API client - /// - /// # Errors - /// - /// If deserializing user agent into a header fails - pub fn new( - user_agent: String, auth: impl Into + Send, - ) -> Result { - let auth = auth.into(); - Ok(Self { - http: Self::http_client(&user_agent, &auth)?, - rate_limiter: http_rate_limiter(), - user_agent, - auth, - }) - } -} - -impl UnauthenticatedResonite { - /// Creates an unauthenticated API client - fn http_client(user_agent: &str) -> Result { - let mut default_headers = HeaderMap::new(); - default_headers.insert( - reqwest::header::ACCEPT, - HeaderValue::from_static("application/json"), - ); - Ok( - Client::builder() - .user_agent(user_agent) - .default_headers(default_headers) - .build()?, - ) - } - - /// Adds authentication to the API client - /// - /// # Errors - /// - /// If deserializing user agent or authentication fails. - pub fn upgrade( - self, auth: impl Into + Send, - ) -> Result { - let auth = auth.into(); - Ok(AuthenticatedResonite { - http: AuthenticatedResonite::http_client(&self.user_agent, &auth)?, - rate_limiter: self.rate_limiter, - user_agent: self.user_agent, - auth, - }) - } - - /// Creates a new Resonite API client - /// - /// # Errors - /// - /// If deserializing user agent into a header fails - pub fn new(user_agent: String) -> Result { - Ok(Self { - http: Self::http_client(&user_agent)?, - rate_limiter: http_rate_limiter(), - user_agent, - }) - } +#[cfg(feature = "signalr_client")] +impl From for ApiError { + fn from(err: ezsockets::Error) -> Self { Self::WebSocket(err) } } diff --git a/src/api_client/signalr.rs b/src/api_client/signalr.rs new file mode 100644 index 0000000..952ee87 --- /dev/null +++ b/src/api_client/signalr.rs @@ -0,0 +1,160 @@ +use async_trait::async_trait; +use serde::Serialize; +use tokio::{ + sync::mpsc::UnboundedSender, + task::{JoinHandle, JoinSet}, +}; +use tokio_stream::wrappers::UnboundedReceiverStream; + +use super::ApiError; +use crate::query::Authentication; + +type ListenMessageResult = Result; + +/// A thread-safe mutex for a stream of receiving messages from the server +pub type ReceiverContainer = std::sync::Arc< + tokio::sync::Mutex>, +>; + +/// A `SignalR` (`WebSocket`) API client +pub struct ResoniteSignalRClient { + receive: ReceiverContainer, + handle: JoinSet<()>, + internal_client: ezsockets::Client, +} + +struct InternalClientExt { + received_sender: UnboundedSender, + connected_sender: UnboundedSender, +} + +impl InternalClientExt { + /// Turns a WS receiving channel to an async streams + fn send_ws_msg(&self, bytes: &[u8]) { + let res: ListenMessageResult = + serde_json::from_slice::(bytes) + .map_err(ApiError::from); + match self.received_sender.send(res) { + Ok(v) => v, + Err(_e) => { + // TODO: Error handling + } + }; + } +} + +#[async_trait] +impl ezsockets::ClientExt for InternalClientExt { + type Call = (); + + async fn on_text(&mut self, text: String) -> Result<(), ezsockets::Error> { + self.send_ws_msg(text.as_bytes()); + Ok(()) + } + + async fn on_binary( + &mut self, bytes: Vec, + ) -> Result<(), ezsockets::Error> { + self.send_ws_msg(&bytes); + Ok(()) + } + + async fn on_call( + &mut self, _params: Self::Call, + ) -> Result<(), ezsockets::Error> { + Ok(()) + } + + async fn on_connect(&mut self) -> Result<(), ezsockets::Error> { + dbg!("Connected!"); + self.connected_sender.send(true).ok(); + + Ok(()) + } +} + +impl ResoniteSignalRClient { + /// Creates a new `SignalR` client + /// + /// # Errors + /// + /// If creating the client/connection fails + pub async fn new( + user_agent: &str, auth: &Authentication, + ) -> Result { + let mut ws_config = ezsockets::ClientConfig::new(crate::SIGNALR_HUB_URI); + + let (header_name, header_value) = auth.to_header(); + ws_config = ws_config.header(header_name, header_value); + ws_config = ws_config.header("User-Agent", user_agent); + + let (received_sender, received_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + + let (connected_sender, mut connected_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + + let (internal_client, future) = ezsockets::connect( + |_client| InternalClientExt { received_sender, connected_sender }, + ws_config, + ) + .await; + + let mut handle = JoinSet::new(); + + if let Err(_e) = internal_client.call(()) { + // TODO: Error handling + } + + handle.spawn(async move { + // Resolves once connection is closed + future.await.ok(); + }); + + let client_clone = internal_client.clone(); + handle.spawn(async move { + loop { + dbg!("Awaiting connection"); + connected_receiver.recv().await; + + client_clone.binary(r#"{"protocol":"json","version":1}"#).ok(); + dbg!("Sent protocol"); + } + }); + + let ws_client = Self { + internal_client, + handle, + receive: std::sync::Arc::new(tokio::sync::Mutex::new( + UnboundedReceiverStream::from(received_receiver), + )), + }; + + Ok(ws_client) + } + + /// Sends a `SignalR` message to the Resonite API. + /// + /// # Errors + /// + /// If something with the request failed. + pub fn send( + &self, requestable: &crate::signalr::Message, + ) -> Result<(), ApiError> { + let data = serde_json::to_string(requestable)?; + self + .internal_client + .binary(data) + .map_err(|e| ApiError::Other(e.to_string()))?; + + Ok(()) + } + + #[must_use] + /// Gets the events sent by the server + pub fn listen(&self) -> ReceiverContainer { self.receive.clone() } +} + +impl Drop for ResoniteSignalRClient { + fn drop(&mut self) { self.handle.abort_all(); } +} diff --git a/src/lib.rs b/src/lib.rs index 252f344..c85636b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,9 @@ #![allow(clippy::multiple_crate_versions)] /// The base path of the API -const API_BASE_URI: &str = "https://api.resonite.com"; +const HTTP_BASE_URI: &str = "https://api.resonite.com"; +#[cfg(feature = "signalr_client")] +const SIGNALR_HUB_URI: &str = "wss://api.resonite.com/hub"; pub mod id; pub mod model; @@ -31,6 +33,7 @@ pub mod util; // The models are split into slightly smaller files in order to avoid a really // long single file. mod assets; +mod signalr; // They are re-exported at the top level though to make importing them easier / // less confusing. diff --git a/src/query/contact.rs b/src/query/contact.rs index 6307b06..a03530d 100644 --- a/src/query/contact.rs +++ b/src/query/contact.rs @@ -7,6 +7,6 @@ pub struct Contacts; impl Queryable> for Contacts { fn url(&self, auth: &Authentication) -> String { - format!("{}/users/{}/contacts", crate::API_BASE_URI, auth.user_id.as_ref()) + format!("{}/users/{}/contacts", crate::HTTP_BASE_URI, auth.user_id.as_ref()) } } diff --git a/src/query/group.rs b/src/query/group.rs index 51102fd..47fc56b 100644 --- a/src/query/group.rs +++ b/src/query/group.rs @@ -19,6 +19,6 @@ impl GroupInfo { impl Queryable for GroupInfo { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/groups/{}", crate::API_BASE_URI, self.group_id.as_ref()) + format!("{}/groups/{}", crate::HTTP_BASE_URI, self.group_id.as_ref()) } } diff --git a/src/query/message.rs b/src/query/message.rs index 6631057..f61e778 100644 --- a/src/query/message.rs +++ b/src/query/message.rs @@ -34,7 +34,7 @@ impl Queryable> for Messages { fn url(&self, auth: &Authentication) -> String { let mut query = format!( "{}/users/{}/messages?maxItems={}", - crate::API_BASE_URI, + crate::HTTP_BASE_URI, auth.user_id.as_ref(), self.max_amount ); @@ -66,7 +66,7 @@ impl Queryable for crate::model::Message { fn url(&self, _: &Authentication) -> String { format!( "{}/users/{}/messages", - crate::API_BASE_URI, + crate::HTTP_BASE_URI, self.recipient_id.as_ref(), ) } diff --git a/src/query/mod.rs b/src/query/mod.rs index 69d117f..350264f 100644 --- a/src/query/mod.rs +++ b/src/query/mod.rs @@ -52,6 +52,17 @@ pub struct Authentication { pub user_id: crate::id::User, } +impl Authentication { + #[must_use] + /// Turns the authentication into the header that it generates + pub fn to_header(&self) -> (&'static str, String) { + ( + "Authorization", + ("res ".to_owned() + self.user_id.as_ref() + ":" + &self.token), + ) + } +} + impl From for Authentication { fn from(value: crate::model::UserSession) -> Self { Self { token: value.token, user_id: value.user_id } diff --git a/src/query/session.rs b/src/query/session.rs index 1fc984d..6a2b781 100644 --- a/src/query/session.rs +++ b/src/query/session.rs @@ -8,7 +8,7 @@ pub struct Sessions; impl Queryable> for Sessions { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/sessions", crate::API_BASE_URI) + format!("{}/sessions", crate::HTTP_BASE_URI) } } @@ -28,6 +28,6 @@ impl SessionInfo { impl Queryable for SessionInfo { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/sessions/{}", crate::API_BASE_URI, self.session_id.as_ref()) + format!("{}/sessions/{}", crate::HTTP_BASE_URI, self.session_id.as_ref()) } } diff --git a/src/query/stats.rs b/src/query/stats.rs index c8013d3..1958d0a 100644 --- a/src/query/stats.rs +++ b/src/query/stats.rs @@ -9,7 +9,7 @@ impl Queryable for OnlineStatistics { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/stats/onlineStats", crate::API_BASE_URI) + format!("{}/stats/onlineStats", crate::HTTP_BASE_URI) } } @@ -20,7 +20,7 @@ impl Queryable for CloudStatistics { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/stats/cloudStats", crate::API_BASE_URI) + format!("{}/stats/cloudStats", crate::HTTP_BASE_URI) } } @@ -29,7 +29,7 @@ pub struct NotifyInstanceOnline(pub crate::id::Machine); impl Queryable for NotifyInstanceOnline { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/stats/instanceOnline/{}", crate::API_BASE_URI, self.0.as_ref()) + format!("{}/stats/instanceOnline/{}", crate::HTTP_BASE_URI, self.0.as_ref()) } fn method(&self, _state: &NoAuthentication) -> RequestMethod { diff --git a/src/query/testing.rs b/src/query/testing.rs index 48afd13..4d57b41 100644 --- a/src/query/testing.rs +++ b/src/query/testing.rs @@ -7,7 +7,7 @@ pub struct Ping; impl Queryable for Ping { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/testing/ping", crate::API_BASE_URI) + format!("{}/testing/ping", crate::HTTP_BASE_URI) } fn deserialize(&self, _data: &[u8]) -> serde_json::Result<()> { Ok(()) } @@ -20,7 +20,7 @@ pub struct HealthCheck; impl Queryable for HealthCheck { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/testing/healthCheck", crate::API_BASE_URI) + format!("{}/testing/healthCheck", crate::HTTP_BASE_URI) } fn deserialize(&self, _data: &[u8]) -> serde_json::Result<()> { Ok(()) } diff --git a/src/query/user.rs b/src/query/user.rs index f3fb72d..df60960 100644 --- a/src/query/user.rs +++ b/src/query/user.rs @@ -83,7 +83,7 @@ impl Queryable for UserInfo { fn url(&self, _: &NoAuthentication) -> String { format!( "{}/users/{}?byUsername={}", - crate::API_BASE_URI, + crate::HTTP_BASE_URI, self.user.as_ref(), &(!self.user.is_id()).to_string() ) @@ -104,6 +104,6 @@ impl UserSearch { impl Queryable> for UserSearch { fn url(&self, _: &NoAuthentication) -> String { - format!("{}/users?name={}", crate::API_BASE_URI, self.name) + format!("{}/users?name={}", crate::HTTP_BASE_URI, self.name) } } diff --git a/src/query/user_session.rs b/src/query/user_session.rs index 3397c68..7bafcd1 100644 --- a/src/query/user_session.rs +++ b/src/query/user_session.rs @@ -130,7 +130,7 @@ impl Queryable for UserSession { fn url(&self, _: &Authenticating) -> String { - format!("{}/userSessions", crate::API_BASE_URI) + format!("{}/userSessions", crate::HTTP_BASE_URI) } fn body( @@ -221,7 +221,7 @@ pub struct ExtendUserSession; impl Queryable for ExtendUserSession { fn url(&self, _: &Authentication) -> String { - format!("{}/userSessions", crate::API_BASE_URI) + format!("{}/userSessions", crate::HTTP_BASE_URI) } fn method(&self, _: &Authentication) -> racal::RequestMethod { diff --git a/src/signalr/mod.rs b/src/signalr/mod.rs new file mode 100644 index 0000000..ea4321b --- /dev/null +++ b/src/signalr/mod.rs @@ -0,0 +1,82 @@ +//! Inefficient but good enough `SignalR` wrappers for `WebSockets` + +// Everything is re-exported from here, and just organized to files +// for easier navigation & better development experience. +#![allow(clippy::module_name_repetitions)] + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +// Modified version of code licensed under MIT from +// https://github.com/yurivoronin/ngx-signalr-websocket/blob/ab6db75462e1a25306c2ffb821008649fd45d6e5/projects/ngx-signalr-websocket/src/lib/protocol.ts +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + strum::Display, + strum::AsRefStr, + strum::VariantNames, +)] +#[serde(tag = "type")] +/// The message type +pub enum Message { + #[serde(rename = "1")] + /// RPC call + Invocation(Invocation), + #[serde(rename = "2")] + /// Data + StreamItem(serde_json::Value), + #[serde(rename = "3")] + /// Invocation completed + Completion(serde_json::Value), + #[serde(rename = "4")] + /// RPC call with streaming + StreamInvocation(serde_json::Value), + #[serde(rename = "5")] + /// Cancel RPC call + CancelInvocation(CancelInvocation), + #[serde(rename = "6")] + /// Keep the connection alive + Ping, + #[serde(rename = "7")] + /// Closes connection + Close, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CancelInvocation { + pub invocation_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Invocation { + pub invocation_id: String, + #[serde(flatten)] + pub data: InvocationData, +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + strum::Display, + strum::AsRefStr, + strum::VariantNames, +)] +#[serde(tag = "target", content = "arguments")] +pub enum InvocationData { + ReceiveSessionUpdate(Box<(crate::model::SessionInfo,)>), + Debug((String,)), + RemoveSession((crate::id::Session, OffsetDateTime)), + #[serde(untagged)] + Unknown(serde_json::Value), +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 72405e7..617847e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -40,3 +40,11 @@ pub fn api_auth() -> AuthenticatedResonite { let auth: Authentication = USER_SESSION.clone().into(); AuthenticatedResonite::new(USER_AGENT.to_string(), auth).unwrap() } + +#[cfg(feature = "signalr_client")] +pub async fn api_signalr() -> resonite::api_client::ResoniteSignalRClient { + let auth: Authentication = USER_SESSION.clone().into(); + resonite::api_client::ResoniteSignalRClient::new(USER_AGENT, &auth) + .await + .unwrap() +} diff --git a/tests/signalr.rs b/tests/signalr.rs new file mode 100644 index 0000000..8c6d150 --- /dev/null +++ b/tests/signalr.rs @@ -0,0 +1,27 @@ +#![cfg(feature = "signalr_client")] + +use resonite::api_client::ApiError; +use tokio_stream::StreamExt; +mod common; + +#[tokio::test] +#[ignore] +async fn listen_signalr() -> Result<(), ApiError> { + let api_client = common::api_signalr().await; + + let listener_ref = api_client.listen(); + let mut listener_lock = listener_ref.lock().await; + + // Listen for a few messages, resonite spams session updates lots so should be + // quick + for _ in 0..3 { + let next = listener_lock + .next() + .await + .expect("WS listener to have next item") + .expect("next WS item to not be err"); + dbg!(&next); + } + + Ok(()) +}