tradestation-api 0.1.0

Complete TradeStation REST API v3 wrapper for Rust
Documentation
//! TradeStation API client with automatic token management.
//!
//! The [`Client`] handles OAuth2 token lifecycle (exchange, refresh, expiration)
//! and provides authenticated HTTP methods for all API endpoints.

use reqwest::header::{self, HeaderMap, HeaderValue};

use crate::Error;
use crate::auth::{self, Credentials, Token};

const BASE_URL: &str = "https://api.tradestation.com";
const SIM_URL: &str = "https://sim-api.tradestation.com";

/// TradeStation API client with automatic token management.
///
/// # Example
///
/// ```no_run
/// use tradestation_api::{Client, Credentials};
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let creds = Credentials::new("id", "secret");
/// let mut client = Client::new(creds);
/// client.authenticate("auth_code").await?;
///
/// let accounts = client.get_accounts().await?;
/// # Ok(())
/// # }
/// ```
pub struct Client {
    pub(crate) http: reqwest::Client,
    credentials: Credentials,
    token: Option<Token>,
    base_url: String,
}

impl Client {
    /// Create a new client targeting the production API.
    pub fn new(credentials: Credentials) -> Self {
        Self {
            http: reqwest::Client::new(),
            credentials,
            token: None,
            base_url: BASE_URL.to_string(),
        }
    }

    /// Use the simulation API instead of production.
    pub fn with_sim(mut self) -> Self {
        self.base_url = SIM_URL.to_string();
        self
    }

    /// Override the base URL (useful for testing with mock servers).
    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = url.into();
        self
    }

    /// Set a pre-existing token (e.g., loaded from persistent storage).
    pub fn with_token(mut self, token: Token) -> Self {
        self.token = Some(token);
        self
    }

    /// Exchange an authorization code for tokens, completing the OAuth2 flow.
    pub async fn authenticate(&mut self, code: &str) -> Result<&Token, Error> {
        let token = auth::exchange_code(&self.http, &self.credentials, code).await?;
        self.token = Some(token);
        Ok(self.token.as_ref().unwrap())
    }

    /// Get a valid access token, automatically refreshing if expired.
    pub async fn access_token(&mut self) -> Result<String, Error> {
        let token = self
            .token
            .as_ref()
            .ok_or_else(|| Error::Auth("Not authenticated".to_string()))?;

        if !token.is_expired() {
            return Ok(token.access_token.clone());
        }

        // Need to refresh
        if let Some(refresh_tok) = &token.refresh_token
            && !token.refresh_expired()
        {
            let new_token = auth::refresh_token(&self.http, &self.credentials, refresh_tok).await?;
            self.token = Some(new_token);
            return Ok(self.token.as_ref().unwrap().access_token.clone());
        }

        Err(Error::Auth(
            "Token expired and cannot be refreshed — re-authenticate".to_string(),
        ))
    }

    /// Build authenticated headers with the current bearer token.
    pub(crate) async fn auth_headers(&mut self) -> Result<HeaderMap, Error> {
        let token = self.access_token().await?;
        let mut headers = HeaderMap::new();
        headers.insert(
            header::AUTHORIZATION,
            HeaderValue::from_str(&format!("Bearer {token}"))
                .map_err(|e| Error::Auth(e.to_string()))?,
        );
        Ok(headers)
    }

    /// Make an authenticated GET request to the given path.
    pub async fn get(&mut self, path: &str) -> Result<reqwest::Response, Error> {
        let headers = self.auth_headers().await?;
        let url = format!("{}{}", self.base_url, path);
        let resp = self.http.get(&url).headers(headers).send().await?;

        if !resp.status().is_success() {
            let status = resp.status().as_u16();
            let body = resp.text().await.unwrap_or_default();
            return Err(Error::Api {
                status,
                message: body,
            });
        }
        Ok(resp)
    }

    /// Make an authenticated POST request with a JSON body.
    pub async fn post<T: serde::Serialize>(
        &mut self,
        path: &str,
        body: &T,
    ) -> Result<reqwest::Response, Error> {
        let headers = self.auth_headers().await?;
        let url = format!("{}{}", self.base_url, path);
        let resp = self
            .http
            .post(&url)
            .headers(headers)
            .json(body)
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status().as_u16();
            let body = resp.text().await.unwrap_or_default();
            return Err(Error::Api {
                status,
                message: body,
            });
        }
        Ok(resp)
    }

    /// Make an authenticated DELETE request.
    pub async fn delete(&mut self, path: &str) -> Result<reqwest::Response, Error> {
        let headers = self.auth_headers().await?;
        let url = format!("{}{}", self.base_url, path);
        let resp = self.http.delete(&url).headers(headers).send().await?;

        if !resp.status().is_success() {
            let status = resp.status().as_u16();
            let body = resp.text().await.unwrap_or_default();
            return Err(Error::Api {
                status,
                message: body,
            });
        }
        Ok(resp)
    }

    /// Make an authenticated PUT request with a JSON body.
    pub async fn put<T: serde::Serialize>(
        &mut self,
        path: &str,
        body: &T,
    ) -> Result<reqwest::Response, Error> {
        let headers = self.auth_headers().await?;
        let url = format!("{}{}", self.base_url, path);
        let resp = self
            .http
            .put(&url)
            .headers(headers)
            .json(body)
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status().as_u16();
            let body = resp.text().await.unwrap_or_default();
            return Err(Error::Api {
                status,
                message: body,
            });
        }
        Ok(resp)
    }

    /// Get the current token (for status display or persistence).
    pub fn token_info(&self) -> Option<&Token> {
        self.token.as_ref()
    }

    /// Get the current base URL.
    pub fn base_url(&self) -> &str {
        &self.base_url
    }
}