Files
foxhunt/bin/fxt/src/grpc.rs
jgrusewski 463b2cd130 refactor(fxt): complete TLI→FXT rename + fix token storage panic
- Rename TliConfig→FxtConfig, TliError→FxtError, TliResult→FxtResult
- Migrate token storage path foxhunt-tli→foxhunt-fxt with auto-migration
- Fix FileTokenStorage::Default panic (fallback to /tmp on missing HOME)
- Update all doc comments, login prompt, config.toml.example, env vars
- Update keyring service names foxhunt-tli-access→foxhunt-fxt-access
- Add TOKEN_REFRESH_BUFFER_SECS named constant
- Add clarifying comments for JWT dummy key and IBKR default client ID

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:43:07 +01:00

180 lines
6.8 KiB
Rust

//! Unified gRPC client for the Foxhunt API Gateway.
//!
//! All service RPCs route through a single [`FoxhuntClient`] which holds
//! one lazily-connected [`Channel`] to the API Gateway. Individual typed
//! clients (trading, ML, risk, ...) are constructed on demand from the
//! shared channel, each wrapped with [`AuthInterceptor`] so every outgoing
//! RPC carries the JWT Bearer token when available.
use anyhow::Result;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;
use crate::auth::interceptor::AuthInterceptor;
use crate::auth::token_manager::{AuthTokenManager, FileTokenStorage};
/// Channel wrapped with the JWT auth interceptor.
type AuthChannel = InterceptedService<Channel, AuthInterceptor<FileTokenStorage>>;
/// Unified gRPC client -- all service accessors share a single channel
/// and automatically inject JWT auth headers via [`AuthInterceptor`].
pub struct FoxhuntClient {
channel: Channel,
interceptor: AuthInterceptor<FileTokenStorage>,
}
impl FoxhuntClient {
/// Create a client that will lazily connect to the API Gateway.
///
/// Sets up the auth interceptor backed by [`FileTokenStorage`] so all
/// outgoing RPCs include the Bearer token when one is cached on disk.
///
/// The TCP handshake is deferred until the first RPC call (`connect_lazy`).
///
/// # Errors
///
/// Returns an error if the URL cannot be parsed or token storage init fails.
pub async fn connect(api_url: &str) -> Result<Self> {
let mut endpoint =
Channel::from_shared(api_url.to_owned()).map_err(|e| anyhow::anyhow!("{e}"))?;
// Auto-configure TLS for https endpoints.
if api_url.starts_with("https://") {
endpoint = endpoint
.tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
.map_err(|e| anyhow::anyhow!("TLS config error: {e}"))?;
}
let channel = endpoint.connect_lazy();
// Auth interceptor: reads cached JWT from ~/.config/foxhunt-fxt/tokens/
// and injects "Authorization: Bearer <token>" on every RPC.
// If no token is cached, requests proceed without auth (allows login).
let storage = FileTokenStorage::new()?;
let auth_manager = AuthTokenManager::new(storage);
let interceptor = AuthInterceptor::new(auth_manager);
Ok(Self {
channel,
interceptor,
})
}
/// Get the underlying bare channel (no auth interceptor).
///
/// Used by [`LoginClient`] which needs unauthenticated access for the
/// initial login RPC before any token exists.
pub fn channel(&self) -> Channel {
self.channel.clone()
}
// ── Typed service accessors ──────────────────────────────────────
/// Trading service client (order management, positions, executions).
pub fn trading(
&self,
) -> crate::proto::trading::trading_service_client::TradingServiceClient<AuthChannel> {
crate::proto::trading::trading_service_client::TradingServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// ML inference service client (predictions, ensemble voting).
pub fn ml(&self) -> crate::proto::ml::ml_service_client::MlServiceClient<AuthChannel> {
crate::proto::ml::ml_service_client::MlServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// ML training service client (start/stop training, checkpoints).
pub fn ml_training(
&self,
) -> crate::proto::ml_training::ml_training_service_client::MlTrainingServiceClient<AuthChannel>
{
crate::proto::ml_training::ml_training_service_client::MlTrainingServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// Broker gateway service client (account info, order routing).
pub fn broker_gateway(
&self,
) -> crate::proto::broker_gateway::broker_gateway_service_client::BrokerGatewayServiceClient<AuthChannel>
{
crate::proto::broker_gateway::broker_gateway_service_client::BrokerGatewayServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// Trading agent service client (autonomous agent control).
pub fn trading_agent(
&self,
) -> crate::proto::trading_agent::trading_agent_service_client::TradingAgentServiceClient<AuthChannel>
{
crate::proto::trading_agent::trading_agent_service_client::TradingAgentServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// Monitoring service client (metrics, alerts, system health).
pub fn monitoring(
&self,
) -> crate::proto::monitoring::monitoring_service_client::MonitoringServiceClient<AuthChannel>
{
crate::proto::monitoring::monitoring_service_client::MonitoringServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// Configuration service client (trading service config -- package `config`).
pub fn config(
&self,
) -> crate::proto::config::config_service_client::ConfigServiceClient<AuthChannel> {
crate::proto::config::config_service_client::ConfigServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// Data acquisition service client (market data feeds, downloads).
pub fn data_acquisition(
&self,
) -> crate::proto::data_acquisition::data_acquisition_service_client::DataAcquisitionServiceClient<AuthChannel>
{
crate::proto::data_acquisition::data_acquisition_service_client::DataAcquisitionServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// Risk service client (VaR, position limits, circuit breakers).
pub fn risk(
&self,
) -> crate::proto::risk::risk_service_client::RiskServiceClient<AuthChannel> {
crate::proto::risk::risk_service_client::RiskServiceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// gRPC health check client (standard `grpc.health.v1.Health`).
///
/// Health checks don't require auth, but the interceptor handles the
/// no-token case gracefully (proceeds without Bearer header).
pub fn health(
&self,
) -> crate::proto::health::health_client::HealthClient<AuthChannel> {
crate::proto::health::health_client::HealthClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
}