Files
foxhunt/crates/ctrader-openapi/src/connection.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
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>
2026-02-25 11:56:00 +01:00

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);
}
}