Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
166 lines
5.2 KiB
Rust
166 lines
5.2 KiB
Rust
//! TCP+TLS connection to the cTrader Open API server.
|
|
//!
|
|
//! Provides a framed, heartbeat-aware transport layer built on
|
|
//! `tokio-native-tls` and `tokio-util::codec`.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use futures::stream::{SplitSink, SplitStream};
|
|
use futures::{SinkExt, StreamExt};
|
|
use tokio::net::TcpStream;
|
|
use tokio::sync::Mutex;
|
|
use tokio_native_tls::TlsStream;
|
|
use tokio_util::codec::Framed;
|
|
use tracing::{debug, info, trace, warn};
|
|
|
|
use crate::codec::CTraderCodec;
|
|
use crate::config::CTraderConfig;
|
|
use crate::error::{CTraderError, Result};
|
|
use crate::proto::ProtoMessage;
|
|
|
|
type FramedTls = Framed<TlsStream<TcpStream>, CTraderCodec>;
|
|
type Sender = SplitSink<FramedTls, ProtoMessage>;
|
|
type Receiver = SplitStream<FramedTls>;
|
|
|
|
/// A TCP+TLS connection to a cTrader API server.
|
|
///
|
|
/// Splits the framed stream into an `Arc<Mutex<Sender>>` (shared between
|
|
/// heartbeat task and caller) and a `Receiver` (consumed by the read loop).
|
|
pub struct CTraderConnection {
|
|
sender: Arc<Mutex<Sender>>,
|
|
receiver: Option<Receiver>,
|
|
heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
impl CTraderConnection {
|
|
/// Establish a TCP+TLS connection (does NOT start heartbeat).
|
|
///
|
|
/// Call `start_heartbeat()` after authentication completes.
|
|
pub async fn connect(config: &CTraderConfig) -> Result<Self> {
|
|
let host = config.environment.host();
|
|
let port = config.environment.port();
|
|
let addr = format!("{host}:{port}");
|
|
|
|
info!(host, port, "connecting to cTrader API");
|
|
|
|
// TCP connect
|
|
let tcp_stream = TcpStream::connect(&addr).await.map_err(|e| {
|
|
CTraderError::ConnectionFailed(format!("TCP connect to {addr} failed: {e}"))
|
|
})?;
|
|
|
|
debug!("TCP connected to {addr}");
|
|
|
|
// TLS handshake
|
|
let native_connector = native_tls::TlsConnector::new().map_err(|e| {
|
|
CTraderError::TlsError(format!("failed to create TLS connector: {e}"))
|
|
})?;
|
|
let tls_connector = tokio_native_tls::TlsConnector::from(native_connector);
|
|
|
|
let tls_stream = tls_connector.connect(host, tcp_stream).await.map_err(|e| {
|
|
CTraderError::TlsError(format!("TLS handshake with {host} failed: {e}"))
|
|
})?;
|
|
|
|
info!("TLS connected to {addr}");
|
|
|
|
// Wrap in codec framing
|
|
let framed = Framed::new(tls_stream, CTraderCodec::new());
|
|
let (raw_sender, receiver) = framed.split();
|
|
let sender = Arc::new(Mutex::new(raw_sender));
|
|
|
|
Ok(Self {
|
|
sender,
|
|
receiver: Some(receiver),
|
|
heartbeat_handle: None,
|
|
})
|
|
}
|
|
|
|
/// Start the heartbeat keepalive task.
|
|
///
|
|
/// Should be called after authentication succeeds.
|
|
pub fn start_heartbeat(&mut self, interval: Duration) {
|
|
let heartbeat_sender = Arc::clone(&self.sender);
|
|
let handle = tokio::spawn(async move {
|
|
Self::heartbeat_loop(heartbeat_sender, interval).await;
|
|
});
|
|
self.heartbeat_handle = Some(handle);
|
|
}
|
|
|
|
/// Send a `ProtoMessage` through the TLS connection.
|
|
pub async fn send(&self, msg: ProtoMessage) -> Result<()> {
|
|
let mut sender = self.sender.lock().await;
|
|
sender.send(msg).await.map_err(|e| {
|
|
CTraderError::ConnectionFailed(format!("failed to send message: {e}"))
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Receive the next message from the connection.
|
|
///
|
|
/// This only works before the dispatcher takes over the receiver.
|
|
pub async fn recv(&mut self) -> Result<ProtoMessage> {
|
|
let receiver = self
|
|
.receiver
|
|
.as_mut()
|
|
.ok_or(CTraderError::NotConnected)?;
|
|
|
|
match receiver.next().await {
|
|
Some(Ok(msg)) => Ok(msg),
|
|
Some(Err(e)) => Err(CTraderError::ConnectionFailed(format!(
|
|
"receive error: {e}"
|
|
))),
|
|
None => Err(CTraderError::NotConnected),
|
|
}
|
|
}
|
|
|
|
/// Take ownership of the receive half (can only be called once).
|
|
pub fn take_receiver(&mut self) -> Option<Receiver> {
|
|
self.receiver.take()
|
|
}
|
|
|
|
/// Internal heartbeat loop — sends `ProtoHeartbeatEvent` on interval.
|
|
async fn heartbeat_loop(sender: Arc<Mutex<Sender>>, interval: Duration) {
|
|
let mut ticker = tokio::time::interval(interval);
|
|
// Skip the first immediate tick.
|
|
ticker.tick().await;
|
|
|
|
loop {
|
|
ticker.tick().await;
|
|
let msg = ProtoMessage {
|
|
payload_type: crate::proto::PT_HEARTBEAT_EVENT,
|
|
payload: None,
|
|
client_msg_id: None,
|
|
};
|
|
trace!("sending heartbeat");
|
|
let mut s = sender.lock().await;
|
|
if let Err(e) = s.send(msg).await {
|
|
warn!("heartbeat send failed: {e}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for CTraderConnection {
|
|
fn drop(&mut self) {
|
|
if let Some(handle) = self.heartbeat_handle.take() {
|
|
handle.abort();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn heartbeat_msg_has_correct_payload_type() {
|
|
let msg = ProtoMessage {
|
|
payload_type: crate::proto::PT_HEARTBEAT_EVENT,
|
|
payload: None,
|
|
client_msg_id: None,
|
|
};
|
|
assert_eq!(msg.payload_type, 51);
|
|
}
|
|
}
|