client: implements event/updates websocket
This commit is contained in:
45
kordophone/src/api/auth.rs
Normal file
45
kordophone/src/api/auth.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
17
kordophone/src/api/event_socket.rs
Normal file
17
kordophone/src/api/event_socket.rs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
17
kordophone/src/model/event.rs
Normal file
17
kordophone/src/model/event.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
21
kordophone/src/model/update.rs
Normal file
21
kordophone/src/model/update.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user