schwab-developer 0.1.0

A library for Charles Schwab API.
Documentation
use std::collections::HashMap;

use base64::{prelude::BASE64_STANDARD, Engine};
use chrono::Utc;
use reqwest::Url;

use crate::{auth::{config::AuthConfig, store::TokenData}, common::constant::BASE_URL};

pub struct OAuthApi {
    config: AuthConfig,
    client: reqwest::Client,
}

impl OAuthApi {
    pub fn new(config: AuthConfig) -> Self {
        Self {
            config,
            client: reqwest::Client::new()
        }
    }

    pub fn generate_auth_url(&self) -> String {
        format!("{}/oauth/authorize?response_type=code&client_id={}&scope=readonly&redirect_uri={}", BASE_URL, self.config.client_id, self.config.redirect_uri)
    }

    pub fn extract_code_from_url(&self, returned_url: &str) -> anyhow::Result<String> {
        let parsed_url = Url::parse(returned_url)?;

        let query_pairs = parsed_url.query_pairs().into_owned().collect::<HashMap<_, _>>();
        let Some(code) = query_pairs.get("code").cloned() else {
            anyhow::bail!("Failed to parse authorization code. Ensure you pasted the entire URL.");
        };
        Ok(code)
    }

    pub fn get_basic_auth_header(&self) -> String {
        let credentials = format!("{}:{}", self.config.client_id, self.config.client_secret);
        let encoded_creds = BASE64_STANDARD.encode(credentials.as_bytes());
        format!("Basic {}", encoded_creds)
    }

    pub async fn exchange_code_for_tokens(&self, auth_code: &str) -> anyhow::Result<TokenData> {
        let url = format!("{}/oauth/token", BASE_URL);

        let mut payload = HashMap::new();
        payload.insert("grant_type", "authorization_code");
        payload.insert("code", auth_code);
        payload.insert("redirect_uri", &self.config.redirect_uri);

        let response = self.client.post(&url)
            .header("Authorization", self.get_basic_auth_header())
            .form(&payload)
            .send().await?;

        let status = response.status();
        if !status.is_success() {
            let text = response.text().await.unwrap_or_default();
            anyhow::bail!(format!("Token exchange failed: {} - {}", status, text));
        }
        let resp_text = response.text().await?;
        let mut token_data = serde_json::from_str::<TokenData>(&resp_text)?;
        token_data.expires_at = Utc::now().timestamp() + token_data.expires_in;
        Ok(token_data)
    }

    pub async fn refresh_access_token(&self, refresh_token: &str) -> anyhow::Result<TokenData> {
        let url = format!("{}/oauth/token", BASE_URL);

        let mut payload = HashMap::new();
        payload.insert("grant_type", "refresh_token");
        payload.insert("refresh_token", refresh_token);

        let response = self.client.post(&url)
            .header("Authorization", self.get_basic_auth_header())
            .form(&payload)
            .send()
            .await?;

        let status = response.status();
        if !status.is_success() {
            let text = response.text().await.unwrap_or_default();
            anyhow::bail!(format!("Token refresh failed: {} - {}", status, text));
        }

        let resp_text = response.text().await?;
        let mut token_data = serde_json::from_str::<TokenData>(&resp_text)?;
        token_data.expires_at = Utc::now().timestamp() + token_data.expires_in;

        if token_data.refresh_token.is_empty() {
            token_data.refresh_token = refresh_token.to_string();
        }

        Ok(token_data)
    }
}