tastyworks 0.11.0

Unofficial Tastyworks API
Documentation
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::{header, Client};

use std::error::Error;
use std::fmt;
use std::path::PathBuf;

pub use reqwest::StatusCode;

const SESSION_ID_KEY: &str = "sessionId";
const API_ENV_KEY: &str = "TASTYWORKS_API_TOKEN";

const RESTART_MSG: &str =
    "Try restarting tastyworks desktop and logging in, even if you are currently logged in.";

fn install_msg() -> String {
    format!("Ensure that tastyworks desktop is installed or define a valid token in the {} environment variable.", API_ENV_KEY)
}

lazy_static! {
    static ref CLIENT: Client = Client::builder()
        .user_agent(
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:79.0) \
             Gecko/20100101 Firefox/79.0"
        )
        .build()
        .unwrap();
}

thread_local! {
    static API_TOKEN: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
}

#[derive(Debug)]
pub enum RequestError {
    TokenMissing,
    SessionKeyMissing,
    FailedRegex {
        obfuscated_line: String,
    },
    Io {
        path: PathBuf,
    },
    ConfigDirMissing,
    Decode {
        e: reqwest::Error,
        url: String,
    },
    FailedRequest {
        e: reqwest::Error,
        url: String,
    },
    FailedResponse {
        status: reqwest::StatusCode,
        url: String,
    },
}

impl Error for RequestError {}

impl fmt::Display for RequestError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::TokenMissing => {
                write!(f, "API token could not be found. {}", RESTART_MSG)
            }
            Self::SessionKeyMissing => {
                write!(f, "Session key could not be found. {}", RESTART_MSG)
            }
            Self::FailedRegex { obfuscated_line } => {
                write!(
                    f,
                    "Preferences json regex failed on line: {}",
                    obfuscated_line
                )
            }
            Self::Io { path } => {
                write!(
                    f,
                    "Error reading file: {}. {}",
                    path.display(),
                    install_msg()
                )
            }
            Self::ConfigDirMissing => {
                write!(f, "Configuration directory not found. {}", install_msg())
            }
            Self::Decode { e, url } => {
                write!(f, "Error decoding {}. {}", url, e)
            }
            Self::FailedRequest { e, url } => {
                write!(f, "Failed request to {}. {}", url, e)
            }
            Self::FailedResponse { status, url } => {
                write!(
                    f,
                    "Failed response ({}) for: {}. {}",
                    status, url, RESTART_MSG
                )
            }
        }
    }
}

pub async fn request(
    url_path: &str,
    params_string: &str,
) -> Result<reqwest::Response, RequestError> {
    let mut api_token_header_value = None;
    let mut error: Option<RequestError> = None;

    API_TOKEN.with(|t| {
        if t.borrow().is_none() {
            if let Ok(token) = std::env::var(API_ENV_KEY) {
                t.replace(Some(token));
            } else {
                match extract_token_from_preferences_file() {
                    Ok(token) => {
                        t.replace(Some(token));
                    }
                    Err(e) => {
                        error = Some(e);
                    }
                }
            }
        };

        if let Some(t) = &*t.borrow() {
            let mut value = header::HeaderValue::from_str(&t).unwrap();
            value.set_sensitive(true);
            api_token_header_value = Some(value);
        }
    });

    if let Some(error) = error {
        return Err(error);
    }

    let params_string = if params_string.is_empty() {
        params_string.to_string()
    } else {
        format!("?{}", params_string)
    };

    let base_url = format!("https://api.tastyworks.com/{}", url_path);
    let url = format!("{}{}", base_url, params_string);

    let response = CLIENT
        .get(&url)
        .header(header::AUTHORIZATION, api_token_header_value.unwrap())
        .send()
        .await;

    match response {
        Err(e) => {
            return Err(RequestError::FailedRequest { e, url });
        }
        Ok(response) => {
            if response.status() != 200 {
                return Err(RequestError::FailedResponse {
                    status: response.status(),
                    url,
                });
            } else {
                Ok(response)
            }
        }
    }
}

fn extract_token_from_preferences_file() -> Result<String, RequestError> {
    lazy_static! {
        static ref RE: Regex =
            Regex::new(&format!(r#""{}"\s*:\s*"([^"]*)"#, SESSION_ID_KEY)).unwrap();
    }

    let mut path: PathBuf = [dirs::home_dir(), dirs::data_local_dir()]
        .iter()
        .flatten()
        .filter_map(|p| {
            let mut p = p.to_path_buf();
            for f in &["tastyworks", ".tastyworks"] {
                p.push(f);
                if p.exists() {
                    return Some(p);
                }
                p.pop();
            }
            None
        })
        .next()
        .ok_or(RequestError::ConfigDirMissing)?;

    path.push("desktop/preferences_user.json");

    let json = std::fs::read_to_string(&path);
    match json {
        Ok(json) => {
            if let Some(m) = RE.captures(&json).and_then(|caps| caps.get(1)) {
                let m_str = m.as_str();
                if m_str.is_empty() {
                    Err(RequestError::TokenMissing)
                } else {
                    Ok(m_str.to_string())
                }
            } else {
                let line = json.lines().find(|line| line.contains(SESSION_ID_KEY));
                if let Some(line) = line {
                    let start_idx = line.find(SESSION_ID_KEY).unwrap();
                    let end_idx = start_idx + SESSION_ID_KEY.len();
                    let obfuscated_line: String = line
                        .char_indices()
                        .map(|(idx, c)| {
                            if c.is_alphanumeric() && (idx < start_idx || idx >= end_idx) {
                                '*'
                            } else {
                                c
                            }
                        })
                        .collect();
                    Err(RequestError::FailedRegex { obfuscated_line })
                } else {
                    Err(RequestError::SessionKeyMissing)
                }
            }
        }
        Err(_) => Err(RequestError::Io { path }),
    }
}