tradestation-api 0.1.0

Complete TradeStation REST API v3 wrapper for Rust
Documentation
//! OAuth2 Authorization Code flow with token refresh for TradeStation API.
//!
//! TradeStation uses OAuth2 with:
//! - Authorization endpoint: `https://signin.tradestation.com/authorize`
//! - Token endpoint: `https://signin.tradestation.com/oauth/token`
//! - Access token lifetime: 20 minutes
//! - Refresh token lifetime: ~30 days
//!
//! # Flow
//!
//! 1. Build an authorization URL with [`Credentials::authorization_url`].
//! 2. User visits the URL and grants access.
//! 3. Exchange the callback code via [`exchange_code`].
//! 4. The [`Token`] auto-refreshes through [`Client::access_token`](crate::Client::access_token).

use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};

use crate::Error;

const AUTH_URL: &str = "https://signin.tradestation.com/authorize";
const TOKEN_URL: &str = "https://signin.tradestation.com/oauth/token";

/// OAuth2 client credentials for the TradeStation API.
///
/// Obtain a `client_id` and `client_secret` from the
/// [TradeStation Developer Portal](https://developer.tradestation.com/).
///
/// # Example
///
/// ```
/// use tradestation_api::Credentials;
///
/// let creds = Credentials::new("my_client_id", "my_secret")
///     .with_redirect_uri("http://localhost:8080/callback");
/// ```
#[derive(Debug, Clone)]
pub struct Credentials {
    /// Application client ID.
    pub client_id: String,
    /// Application client secret.
    pub client_secret: String,
    /// OAuth2 redirect URI. Defaults to `http://localhost:3000/callback`.
    pub redirect_uri: String,
}

impl Credentials {
    /// Create new credentials with the default redirect URI.
    pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
        Self {
            client_id: client_id.into(),
            client_secret: client_secret.into(),
            redirect_uri: "http://localhost:3000/callback".to_string(),
        }
    }

    /// Override the redirect URI (builder pattern).
    pub fn with_redirect_uri(mut self, uri: impl Into<String>) -> Self {
        self.redirect_uri = uri.into();
        self
    }

    /// Build the authorization URL for the user to visit.
    ///
    /// The returned URL should be opened in a browser. After the user grants
    /// access, TradeStation redirects to `redirect_uri` with a `code` parameter.
    pub fn authorization_url(&self, scopes: &[Scope]) -> String {
        let scope_str: String = scopes
            .iter()
            .map(|s| s.as_str())
            .collect::<Vec<_>>()
            .join(" ");
        format!(
            "{}?response_type=code&client_id={}&redirect_uri={}&audience=https://api.tradestation.com&scope={}",
            AUTH_URL,
            urlencoding::encode(&self.client_id),
            urlencoding::encode(&self.redirect_uri),
            urlencoding::encode(&scope_str),
        )
    }
}

/// OAuth2 scopes controlling API access levels.
///
/// Use [`Scope::defaults`] for the most common combination.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
    /// Access to market data endpoints.
    MarketData,
    /// Read-only access to brokerage accounts.
    ReadAccount,
    /// Permission to place and manage orders.
    Trade,
    /// Access to option spread endpoints.
    OptionSpreads,
    /// Access to the Matrix order entry.
    Matrix,
    /// OpenID Connect profile scope.
    OpenId,
    /// Enables refresh tokens for offline access.
    OfflineAccess,
}

impl Scope {
    /// Return the OAuth2 scope string value.
    pub fn as_str(&self) -> &'static str {
        match self {
            Scope::MarketData => "MarketData",
            Scope::ReadAccount => "ReadAccount",
            Scope::Trade => "Trade",
            Scope::OptionSpreads => "OptionSpreads",
            Scope::Matrix => "Matrix",
            Scope::OpenId => "openid",
            Scope::OfflineAccess => "offline_access",
        }
    }

    /// Default scopes for full API access: MarketData, ReadAccount, Trade,
    /// OptionSpreads, Matrix, OpenId, OfflineAccess.
    pub fn defaults() -> Vec<Scope> {
        vec![
            Scope::MarketData,
            Scope::ReadAccount,
            Scope::Trade,
            Scope::OptionSpreads,
            Scope::Matrix,
            Scope::OpenId,
            Scope::OfflineAccess,
        ]
    }
}

/// OAuth2 token with expiration tracking.
///
/// Access tokens expire after ~20 minutes. The client automatically refreshes
/// them using the refresh token when available.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Token {
    /// The bearer access token.
    pub access_token: String,
    /// Refresh token for obtaining new access tokens.
    pub refresh_token: Option<String>,
    /// Token type (typically "Bearer").
    pub token_type: String,
    /// When the access token expires.
    pub expires_at: DateTime<Utc>,
    /// When the refresh token expires (~30 days from issue).
    pub refresh_expires_at: Option<DateTime<Utc>>,
}

impl Token {
    /// Check if the access token is expired (with 2-minute safety buffer).
    pub fn is_expired(&self) -> bool {
        Utc::now() + Duration::minutes(2) >= self.expires_at
    }

    /// Check if the refresh token is expired.
    pub fn refresh_expired(&self) -> bool {
        self.refresh_expires_at
            .is_some_and(|expires| Utc::now() >= expires)
    }

    /// Whether this token can be refreshed (has a refresh token that is not expired).
    pub fn can_refresh(&self) -> bool {
        self.refresh_token.is_some() && !self.refresh_expired()
    }
}

/// Token response from TradeStation OAuth2 endpoint.
#[derive(Debug, Deserialize)]
struct TokenResponse {
    access_token: String,
    refresh_token: Option<String>,
    token_type: String,
    expires_in: i64,
}

/// Exchange an authorization code for an access/refresh token pair.
///
/// This is step 3 of the OAuth2 flow. The `code` comes from the redirect URI
/// query parameter after the user authorizes the application.
///
/// # Errors
///
/// Returns [`Error::Auth`] if the token exchange fails.
pub async fn exchange_code(
    http: &reqwest::Client,
    credentials: &Credentials,
    code: &str,
) -> Result<Token, Error> {
    let resp = http
        .post(TOKEN_URL)
        .form(&[
            ("grant_type", "authorization_code"),
            ("code", code),
            ("client_id", &credentials.client_id),
            ("client_secret", &credentials.client_secret),
            ("redirect_uri", &credentials.redirect_uri),
        ])
        .send()
        .await?;

    if !resp.status().is_success() {
        let status = resp.status().as_u16();
        let body = resp.text().await.unwrap_or_default();
        return Err(Error::Auth(format!(
            "Token exchange failed ({status}): {body}"
        )));
    }

    let token_resp: TokenResponse = resp.json().await?;
    let now = Utc::now();

    Ok(Token {
        access_token: token_resp.access_token,
        refresh_token: token_resp.refresh_token,
        token_type: token_resp.token_type,
        expires_at: now + Duration::seconds(token_resp.expires_in),
        refresh_expires_at: Some(now + Duration::days(30)),
    })
}

/// Revoke a refresh token, invalidating it for future use.
///
/// # Errors
///
/// Returns [`Error::Auth`] if the revocation request fails.
pub async fn revoke_token(
    http: &reqwest::Client,
    credentials: &Credentials,
    token: &str,
) -> Result<(), Error> {
    let resp = http
        .post(TOKEN_URL)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .form(&[
            ("token", token),
            ("token_type_hint", "refresh_token"),
            ("client_id", &credentials.client_id),
            ("client_secret", &credentials.client_secret),
        ])
        .send()
        .await?;

    if !resp.status().is_success() {
        let status = resp.status().as_u16();
        let body = resp.text().await.unwrap_or_default();
        return Err(Error::Auth(format!(
            "Token revocation failed ({status}): {body}"
        )));
    }

    Ok(())
}

/// Refresh an expired access token using the refresh token.
///
/// # Errors
///
/// Returns [`Error::Auth`] if the refresh request fails.
pub async fn refresh_token(
    http: &reqwest::Client,
    credentials: &Credentials,
    refresh_tok: &str,
) -> Result<Token, Error> {
    let resp = http
        .post(TOKEN_URL)
        .form(&[
            ("grant_type", "refresh_token"),
            ("refresh_token", refresh_tok),
            ("client_id", &credentials.client_id),
            ("client_secret", &credentials.client_secret),
        ])
        .send()
        .await?;

    if !resp.status().is_success() {
        let status = resp.status().as_u16();
        let body = resp.text().await.unwrap_or_default();
        return Err(Error::Auth(format!(
            "Token refresh failed ({status}): {body}"
        )));
    }

    let token_resp: TokenResponse = resp.json().await?;
    let now = Utc::now();

    Ok(Token {
        access_token: token_resp.access_token,
        refresh_token: token_resp.refresh_token,
        token_type: token_resp.token_type,
        expires_at: now + Duration::seconds(token_resp.expires_in),
        refresh_expires_at: Some(now + Duration::days(30)),
    })
}