wave-api 0.1.0

Typed Rust client for the Wave Accounting GraphQL API
Documentation
use serde::de::DeserializeOwned;
use serde_json::json;

use crate::auth::{AuthState, OAuthConfig};
use crate::error::{GraphqlError, InputError, WaveError};

const WAVE_GRAPHQL_URL: &str = "https://gql.waveapps.com/graphql/public";

/// Client for the Wave Accounting GraphQL API.
#[derive(Clone)]
pub struct WaveClient {
    http: reqwest::Client,
    pub(crate) auth: AuthState,
}

impl WaveClient {
    /// Create a new client with OAuth credentials.
    pub fn with_oauth(config: OAuthConfig) -> Self {
        Self {
            http: reqwest::Client::new(),
            auth: AuthState::new(config),
        }
    }

    /// Execute a raw GraphQL query/mutation and return the full JSON `data` object.
    pub(crate) async fn execute(
        &self,
        query: &str,
        variables: serde_json::Value,
    ) -> Result<serde_json::Value, WaveError> {
        let body = json!({
            "query": query,
            "variables": variables,
        });

        let resp = self.send_request(&body).await?;

        // Check for GraphQL errors.
        if let Some(errors) = resp.get("errors") {
            let gql_errors: Vec<GraphqlError> = serde_json::from_value(errors.clone())?;

            // If there's also data, some fields resolved — but if data is all null, treat as error.
            if resp.get("data").is_some_and(|d| !d.is_null()) {
                // Partial success — check for UNAUTHENTICATED specifically.
                let is_unauth = gql_errors
                    .iter()
                    .any(|e| {
                        e.extensions
                            .as_ref()
                            .and_then(|ext| ext.code.as_deref())
                            == Some("UNAUTHENTICATED")
                    });

                if is_unauth {
                    // Try refresh and retry once.
                    return self.retry_after_refresh(query, variables).await;
                }

                // Return data with errors (partial success)
                // For now, return the error since most callers expect full success.
                return Err(WaveError::GraphQL(gql_errors));
            }

            // Check if it's an auth error and retry.
            let is_unauth = gql_errors
                .iter()
                .any(|e| {
                    e.extensions
                        .as_ref()
                        .and_then(|ext| ext.code.as_deref())
                        == Some("UNAUTHENTICATED")
                });

            if is_unauth {
                return self.retry_after_refresh(query, variables).await;
            }

            return Err(WaveError::GraphQL(gql_errors));
        }

        resp.get("data")
            .cloned()
            .ok_or_else(|| {
                WaveError::Json(serde_json::from_str::<serde_json::Value>("\"missing data\"").unwrap_err())
            })
    }

    /// Send the HTTP request with current auth token.
    async fn send_request(&self, body: &serde_json::Value) -> Result<serde_json::Value, WaveError> {
        let token = self.auth.access_token().await;
        let resp = self
            .http
            .post(WAVE_GRAPHQL_URL)
            .bearer_auth(&token)
            .json(body)
            .send()
            .await?;

        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
            return Err(WaveError::Auth("401 Unauthorized".into()));
        }

        let json: serde_json::Value = resp.json().await?;
        Ok(json)
    }

    /// Refresh token, then retry the query once.
    async fn retry_after_refresh(
        &self,
        query: &str,
        variables: serde_json::Value,
    ) -> Result<serde_json::Value, WaveError> {
        self.auth.refresh(&self.http).await?;

        let body = json!({
            "query": query,
            "variables": variables,
        });

        let resp = self.send_request(&body).await?;

        if let Some(errors) = resp.get("errors") {
            let gql_errors: Vec<GraphqlError> = serde_json::from_value(errors.clone())?;
            return Err(WaveError::GraphQL(gql_errors));
        }

        resp.get("data")
            .cloned()
            .ok_or_else(|| WaveError::Auth("missing data after retry".into()))
    }

    /// Execute a mutation and handle the `didSucceed` / `inputErrors` pattern.
    pub(crate) async fn execute_mutation<T: DeserializeOwned>(
        &self,
        query: &str,
        variables: serde_json::Value,
        result_key: &str,
    ) -> Result<T, WaveError> {
        let data = self.execute(query, variables).await?;

        let result = data
            .get(result_key)
            .ok_or_else(|| WaveError::Auth(format!("missing key '{result_key}' in response")))?;

        let did_succeed = result
            .get("didSucceed")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);

        if !did_succeed {
            if let Some(errors) = result.get("inputErrors") {
                let input_errors: Vec<InputError> = serde_json::from_value(errors.clone())?;
                if !input_errors.is_empty() {
                    return Err(WaveError::MutationFailed(input_errors));
                }
            }
            return Err(WaveError::MutationFailed(vec![]));
        }

        let parsed: T = serde_json::from_value(result.clone())?;
        Ok(parsed)
    }
}