wave-api 0.1.0

Typed Rust client for the Wave Accounting GraphQL API
Documentation
use std::sync::Arc;
use tokio::sync::RwLock;

use crate::error::WaveError;

const TOKEN_URL: &str = "https://api.waveapps.com/oauth2/token/";

/// Callback type for token refresh notifications.
pub type TokenRefreshCallback = Arc<dyn Fn(&str, &str) + Send + Sync>;

/// OAuth2 configuration for the Wave API.
#[derive(Clone)]
pub struct OAuthConfig {
    pub client_id: String,
    pub client_secret: String,
    pub access_token: String,
    pub refresh_token: String,
    pub redirect_uri: String,
    /// Optional callback invoked after a token refresh with (new_access_token, new_refresh_token).
    pub on_token_refresh: Option<TokenRefreshCallback>,
}

/// Internal auth state shared across clones.
#[derive(Clone)]
pub(crate) struct AuthState {
    pub(crate) config: OAuthConfig,
    pub(crate) tokens: Arc<RwLock<TokenPair>>,
}

/// Current access/refresh token pair.
pub(crate) struct TokenPair {
    pub access_token: String,
    pub refresh_token: String,
}

impl AuthState {
    pub fn new(config: OAuthConfig) -> Self {
        let tokens = TokenPair {
            access_token: config.access_token.clone(),
            refresh_token: config.refresh_token.clone(),
        };
        Self {
            config,
            tokens: Arc::new(RwLock::new(tokens)),
        }
    }

    pub async fn access_token(&self) -> String {
        self.tokens.read().await.access_token.clone()
    }

    /// Refresh the access token using the refresh token.
    pub async fn refresh(&self, http: &reqwest::Client) -> Result<(), WaveError> {
        let refresh_token = self.tokens.read().await.refresh_token.clone();

        let params = [
            ("client_id", self.config.client_id.as_str()),
            ("client_secret", self.config.client_secret.as_str()),
            ("refresh_token", refresh_token.as_str()),
            ("grant_type", "refresh_token"),
            ("redirect_uri", self.config.redirect_uri.as_str()),
        ];

        let resp = http
            .post(TOKEN_URL)
            .form(&params)
            .send()
            .await
            .map_err(|e| WaveError::TokenRefresh(e.to_string()))?;

        if !resp.status().is_success() {
            let status = resp.status();
            let body = resp.text().await.unwrap_or_default();
            return Err(WaveError::TokenRefresh(format!(
                "HTTP {status}{body}"
            )));
        }

        let body: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| WaveError::TokenRefresh(e.to_string()))?;

        let new_access = body["access_token"]
            .as_str()
            .ok_or_else(|| WaveError::TokenRefresh("missing access_token in response".into()))?;
        let new_refresh = body["refresh_token"]
            .as_str()
            .ok_or_else(|| WaveError::TokenRefresh("missing refresh_token in response".into()))?;

        // Update stored tokens.
        {
            let mut tokens = self.tokens.write().await;
            tokens.access_token = new_access.to_string();
            tokens.refresh_token = new_refresh.to_string();
        }

        // Notify callback.
        if let Some(cb) = &self.config.on_token_refresh {
            cb(new_access, new_refresh);
        }

        Ok(())
    }
}