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";
#[derive(Debug, Clone)]
pub struct Credentials {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
}
impl Credentials {
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(),
}
}
pub fn with_redirect_uri(mut self, uri: impl Into<String>) -> Self {
self.redirect_uri = uri.into();
self
}
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),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
MarketData,
ReadAccount,
Trade,
OptionSpreads,
Matrix,
OpenId,
OfflineAccess,
}
impl Scope {
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",
}
}
pub fn defaults() -> Vec<Scope> {
vec![
Scope::MarketData,
Scope::ReadAccount,
Scope::Trade,
Scope::OptionSpreads,
Scope::Matrix,
Scope::OpenId,
Scope::OfflineAccess,
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Token {
pub access_token: String,
pub refresh_token: Option<String>,
pub token_type: String,
pub expires_at: DateTime<Utc>,
pub refresh_expires_at: Option<DateTime<Utc>>,
}
impl Token {
pub fn is_expired(&self) -> bool {
Utc::now() + Duration::minutes(2) >= self.expires_at
}
pub fn refresh_expired(&self) -> bool {
self.refresh_expires_at
.is_some_and(|expires| Utc::now() >= expires)
}
pub fn can_refresh(&self) -> bool {
self.refresh_token.is_some() && !self.refresh_expired()
}
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
token_type: String,
expires_in: i64,
}
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)),
})
}
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(())
}
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)),
})
}