Private
Public Access
1
0

client: implements event/updates websocket

This commit is contained in:
2025-05-01 18:07:18 -07:00
parent 13a78ccd47
commit f6ac3b5a58
14 changed files with 561 additions and 67 deletions

View File

@@ -0,0 +1,45 @@
use crate::api::Credentials;
use crate::api::JwtToken;
use async_trait::async_trait;
#[async_trait]
pub trait AuthenticationStore {
async fn get_credentials(&mut self) -> Option<Credentials>;
async fn get_token(&mut self) -> Option<JwtToken>;
async fn set_token(&mut self, token: JwtToken);
}
pub struct InMemoryAuthenticationStore {
credentials: Option<Credentials>,
token: Option<JwtToken>,
}
impl Default for InMemoryAuthenticationStore {
fn default() -> Self {
Self::new(None)
}
}
impl InMemoryAuthenticationStore {
pub fn new(credentials: Option<Credentials>) -> Self {
Self {
credentials,
token: None,
}
}
}
#[async_trait]
impl AuthenticationStore for InMemoryAuthenticationStore {
async fn get_credentials(&mut self) -> Option<Credentials> {
self.credentials.clone()
}
async fn get_token(&mut self) -> Option<JwtToken> {
self.token.clone()
}
async fn set_token(&mut self, token: JwtToken) {
self.token = Some(token);
}
}

View File

@@ -0,0 +1,17 @@
use async_trait::async_trait;
use crate::model::update::UpdateItem;
use crate::model::event::Event;
use futures_util::stream::Stream;
#[async_trait]
pub trait EventSocket {
type Error;
type EventStream: Stream<Item = Result<Event, Self::Error>>;
type UpdateStream: Stream<Item = Result<Vec<UpdateItem>, Self::Error>>;
/// Modern event pipeline
async fn events(self) -> Self::EventStream;
/// Raw update items from the v1 API.
async fn raw_updates(self) -> Self::UpdateStream;
}

View File

@@ -4,13 +4,25 @@ extern crate serde;
use std::{path::PathBuf, str};
use crate::api::AuthenticationStore;
use crate::api::event_socket::EventSocket;
use hyper::{Body, Client, Method, Request, Uri};
use async_trait::async_trait;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tokio::net::TcpStream;
use futures_util::{StreamExt, TryStreamExt};
use futures_util::stream::{SplitStream, SplitSink, TryFilterMap, MapErr, Stream};
use futures_util::stream::Map;
use futures_util::stream::BoxStream;
use std::future::Future;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use crate::{
model::{Conversation, ConversationID, JwtToken, Message, MessageID},
model::{Conversation, ConversationID, JwtToken, Message, MessageID, UpdateItem, Event},
APIInterface
};
@@ -63,6 +75,12 @@ impl From <serde_json::Error> for Error {
}
}
impl From <tungstenite::Error> for Error {
fn from(err: tungstenite::Error) -> Error {
Error::ClientError(err.to_string())
}
}
trait AuthBuilder {
fn with_auth(self, token: &Option<JwtToken>) -> Self;
}
@@ -90,6 +108,58 @@ impl<B> AuthSetting for hyper::http::Request<B> {
}
}
type WebsocketSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, tungstenite::Message>;
type WebsocketStream = SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>;
pub struct WebsocketEventSocket {
_sink: WebsocketSink,
stream: WebsocketStream,
}
impl WebsocketEventSocket {
pub fn new(socket: WebSocketStream<MaybeTlsStream<TcpStream>>) -> Self {
let (sink, stream) = socket.split();
Self { _sink: sink, stream }
}
}
impl WebsocketEventSocket {
fn raw_update_stream(self) -> impl Stream<Item = Result<Vec<UpdateItem>, Error>> {
self.stream
.map_err(Error::from)
.try_filter_map(|msg| async move {
match msg {
tungstenite::Message::Text(text) => {
serde_json::from_str::<Vec<UpdateItem>>(&text)
.map(Some)
.map_err(Error::from)
}
_ => Ok(None)
}
})
}
}
#[async_trait]
impl EventSocket for WebsocketEventSocket {
type Error = Error;
type EventStream = BoxStream<'static, Result<Event, Error>>;
type UpdateStream = BoxStream<'static, Result<Vec<UpdateItem>, Error>>;
async fn events(self) -> Self::EventStream {
use futures_util::stream::iter;
self.raw_update_stream()
.map_ok(|updates| iter(updates.into_iter().map(|update| Ok(Event::from(update)))))
.try_flatten()
.boxed()
}
async fn raw_updates(self) -> Self::UpdateStream {
self.raw_update_stream().boxed()
}
}
#[async_trait]
impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
type Error = Error;
@@ -146,6 +216,44 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let messages: Vec<Message> = self.request(&endpoint, Method::GET).await?;
Ok(messages)
}
async fn open_event_socket(&mut self) -> Result<WebsocketEventSocket, Self::Error> {
use tungstenite::http::StatusCode;
use tungstenite::handshake::client::Request as TungsteniteRequest;
use tungstenite::handshake::client::generate_key;
let uri = self.uri_for_endpoint("updates", Some(self.websocket_scheme()));
log::debug!("Connecting to websocket: {:?}", uri);
let auth = self.auth_store.get_token().await;
let host = uri.authority().unwrap().host();
let mut request = TungsteniteRequest::builder()
.header("Host", host)
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.uri(uri.to_string())
.body(())
.expect("Unable to build websocket request");
log::debug!("Websocket request: {:?}", request);
if let Some(token) = &auth {
let header_value = token.to_header_value().to_str().unwrap().parse().unwrap(); // ugh
request.headers_mut().insert("Authorization", header_value);
}
let (socket, response) = connect_async(request).await.unwrap();
log::debug!("Websocket connected: {:?}", response.status());
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
return Err(Error::ClientError("Websocket connection failed".into()));
}
Ok(WebsocketEventSocket::new(socket))
}
}
impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
@@ -157,15 +265,27 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
}
}
fn uri_for_endpoint(&self, endpoint: &str) -> Uri {
fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Uri {
let mut parts = self.base_url.clone().into_parts();
let root_path: PathBuf = parts.path_and_query.unwrap().path().into();
let path = root_path.join(endpoint);
parts.path_and_query = Some(path.to_str().unwrap().parse().unwrap());
if let Some(scheme) = scheme {
parts.scheme = Some(scheme.parse().unwrap());
}
Uri::try_from(parts).unwrap()
}
fn websocket_scheme(&self) -> &str {
if self.base_url.scheme().unwrap() == "https" {
"wss"
} else {
"ws"
}
}
async fn request<T: DeserializeOwned>(&mut self, endpoint: &str, method: Method) -> Result<T, Error> {
self.request_with_body(endpoint, method, || { Body::empty() }).await
}
@@ -188,7 +308,7 @@ impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
{
use hyper::StatusCode;
let uri = self.uri_for_endpoint(endpoint);
let uri = self.uri_for_endpoint(endpoint, None);
log::debug!("Requesting {:?} {:?}", method, uri);
let build_request = move |auth: &Option<JwtToken>| {
@@ -320,4 +440,18 @@ mod test {
let messages = client.get_messages(&conversation.guid, None, None, None).await.unwrap();
assert!(!messages.is_empty());
}
#[tokio::test]
async fn test_updates() {
if !mock_client_is_reachable().await {
log::warn!("Skipping http_client tests (mock server not reachable)");
return;
}
let mut client = local_mock_client();
// We just want to see if the connection is established, we won't wait for any events
let _ = client.open_event_socket().await.unwrap();
assert!(true);
}
}

View File

@@ -2,11 +2,18 @@ use async_trait::async_trait;
pub use crate::model::{
Conversation, Message, ConversationID, MessageID,
};
pub mod auth;
pub use crate::api::auth::{AuthenticationStore, InMemoryAuthenticationStore};
use crate::model::JwtToken;
pub mod http_client;
pub use http_client::HTTPAPIClient;
pub mod event_socket;
pub use event_socket::EventSocket;
use self::http_client::Credentials;
#[async_trait]
@@ -30,46 +37,7 @@ pub trait APIInterface {
// (POST) /authenticate
async fn authenticate(&mut self, credentials: Credentials) -> Result<JwtToken, Self::Error>;
}
#[async_trait]
pub trait AuthenticationStore {
async fn get_credentials(&mut self) -> Option<Credentials>;
async fn get_token(&mut self) -> Option<JwtToken>;
async fn set_token(&mut self, token: JwtToken);
}
pub struct InMemoryAuthenticationStore {
credentials: Option<Credentials>,
token: Option<JwtToken>,
}
impl Default for InMemoryAuthenticationStore {
fn default() -> Self {
Self::new(None)
}
}
impl InMemoryAuthenticationStore {
pub fn new(credentials: Option<Credentials>) -> Self {
Self {
credentials,
token: None,
}
}
}
#[async_trait]
impl AuthenticationStore for InMemoryAuthenticationStore {
async fn get_credentials(&mut self) -> Option<Credentials> {
self.credentials.clone()
}
async fn get_token(&mut self) -> Option<JwtToken> {
self.token.clone()
}
async fn set_token(&mut self, token: JwtToken) {
self.token = Some(token);
}
// (WS) /updates
async fn open_event_socket(&mut self) -> Result<impl EventSocket, Self::Error>;
}

View File

@@ -0,0 +1,17 @@
use crate::model::{Conversation, Message, UpdateItem};
#[derive(Debug, Clone)]
pub enum Event {
ConversationChanged(Conversation),
MessageReceived(Conversation, Message),
}
impl From<UpdateItem> for Event {
fn from(update: UpdateItem) -> Self {
match update {
UpdateItem { conversation: Some(conversation), message: None, .. } => Event::ConversationChanged(conversation),
UpdateItem { conversation: Some(conversation), message: Some(message), .. } => Event::MessageReceived(conversation, message),
_ => panic!("Invalid update item: {:?}", update),
}
}
}

View File

@@ -1,5 +1,7 @@
pub mod conversation;
pub mod event;
pub mod message;
pub mod update;
pub use conversation::Conversation;
pub use conversation::ConversationID;
@@ -7,6 +9,10 @@ pub use conversation::ConversationID;
pub use message::Message;
pub use message::MessageID;
pub use update::UpdateItem;
pub use event::Event;
pub mod jwt;
pub use jwt::JwtToken;

View File

@@ -0,0 +1,21 @@
use serde::Deserialize;
use super::conversation::Conversation;
use super::message::Message;
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateItem {
#[serde(rename = "messageSequenceNumber")]
pub seq: u64,
#[serde(rename = "conversation")]
pub conversation: Option<Conversation>,
#[serde(rename = "message")]
pub message: Option<Message>,
}
impl Default for UpdateItem {
fn default() -> Self {
Self { seq: 0, conversation: None, message: None }
}
}

View File

@@ -4,8 +4,12 @@ use std::collections::HashMap;
pub use crate::APIInterface;
use crate::{
api::http_client::Credentials,
model::{Conversation, ConversationID, JwtToken, Message, MessageID}
};
model::{Conversation, ConversationID, JwtToken, Message, MessageID, UpdateItem, Event},
api::event_socket::EventSocket,
};
use futures_util::StreamExt;
use futures_util::stream::BoxStream;
pub struct TestClient {
pub version: &'static str,
@@ -28,6 +32,32 @@ impl TestClient {
}
}
pub struct TestEventSocket {
pub events: Vec<Event>,
}
impl TestEventSocket {
pub fn new() -> Self {
Self { events: vec![] }
}
}
#[async_trait]
impl EventSocket for TestEventSocket {
type Error = TestError;
type EventStream = BoxStream<'static, Result<Event, TestError>>;
type UpdateStream = BoxStream<'static, Result<Vec<UpdateItem>, TestError>>;
async fn events(self) -> Self::EventStream {
futures_util::stream::iter(self.events.into_iter().map(Ok)).boxed()
}
async fn raw_updates(self) -> Self::UpdateStream {
let results: Vec<Result<Vec<UpdateItem>, TestError>> = vec![];
futures_util::stream::iter(results.into_iter()).boxed()
}
}
#[async_trait]
impl APIInterface for TestClient {
type Error = TestError;
@@ -57,4 +87,10 @@ impl APIInterface for TestClient {
Err(TestError::ConversationNotFound)
}
async fn open_event_socket(&mut self) -> Result<impl EventSocket, Self::Error> {
Ok(TestEventSocket::new())
}
}