Skip to main content

wave_api/
client.rs

1use serde::de::DeserializeOwned;
2use serde_json::json;
3
4use crate::auth::{AuthState, OAuthConfig};
5use crate::error::{GraphqlError, InputError, WaveError};
6
7const WAVE_GRAPHQL_URL: &str = "https://gql.waveapps.com/graphql/public";
8
9/// Client for the Wave Accounting GraphQL API.
10#[derive(Clone)]
11pub struct WaveClient {
12    http: reqwest::Client,
13    pub(crate) auth: AuthState,
14}
15
16impl WaveClient {
17    /// Create a new client with OAuth credentials.
18    pub fn with_oauth(config: OAuthConfig) -> Self {
19        Self {
20            http: reqwest::Client::new(),
21            auth: AuthState::new(config),
22        }
23    }
24
25    /// Execute a raw GraphQL query/mutation and return the full JSON `data` object.
26    pub(crate) async fn execute(
27        &self,
28        query: &str,
29        variables: serde_json::Value,
30    ) -> Result<serde_json::Value, WaveError> {
31        let body = json!({
32            "query": query,
33            "variables": variables,
34        });
35
36        let resp = self.send_request(&body).await?;
37
38        // Check for GraphQL errors.
39        if let Some(errors) = resp.get("errors") {
40            let gql_errors: Vec<GraphqlError> = serde_json::from_value(errors.clone())?;
41
42            // If there's also data, some fields resolved — but if data is all null, treat as error.
43            if resp.get("data").is_some_and(|d| !d.is_null()) {
44                // Partial success — check for UNAUTHENTICATED specifically.
45                let is_unauth = gql_errors
46                    .iter()
47                    .any(|e| {
48                        e.extensions
49                            .as_ref()
50                            .and_then(|ext| ext.code.as_deref())
51                            == Some("UNAUTHENTICATED")
52                    });
53
54                if is_unauth {
55                    // Try refresh and retry once.
56                    return self.retry_after_refresh(query, variables).await;
57                }
58
59                // Return data with errors (partial success)
60                // For now, return the error since most callers expect full success.
61                return Err(WaveError::GraphQL(gql_errors));
62            }
63
64            // Check if it's an auth error and retry.
65            let is_unauth = gql_errors
66                .iter()
67                .any(|e| {
68                    e.extensions
69                        .as_ref()
70                        .and_then(|ext| ext.code.as_deref())
71                        == Some("UNAUTHENTICATED")
72                });
73
74            if is_unauth {
75                return self.retry_after_refresh(query, variables).await;
76            }
77
78            return Err(WaveError::GraphQL(gql_errors));
79        }
80
81        resp.get("data")
82            .cloned()
83            .ok_or_else(|| {
84                WaveError::Json(serde_json::from_str::<serde_json::Value>("\"missing data\"").unwrap_err())
85            })
86    }
87
88    /// Send the HTTP request with current auth token.
89    async fn send_request(&self, body: &serde_json::Value) -> Result<serde_json::Value, WaveError> {
90        let token = self.auth.access_token().await;
91        let resp = self
92            .http
93            .post(WAVE_GRAPHQL_URL)
94            .bearer_auth(&token)
95            .json(body)
96            .send()
97            .await?;
98
99        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
100            return Err(WaveError::Auth("401 Unauthorized".into()));
101        }
102
103        let json: serde_json::Value = resp.json().await?;
104        Ok(json)
105    }
106
107    /// Refresh token, then retry the query once.
108    async fn retry_after_refresh(
109        &self,
110        query: &str,
111        variables: serde_json::Value,
112    ) -> Result<serde_json::Value, WaveError> {
113        self.auth.refresh(&self.http).await?;
114
115        let body = json!({
116            "query": query,
117            "variables": variables,
118        });
119
120        let resp = self.send_request(&body).await?;
121
122        if let Some(errors) = resp.get("errors") {
123            let gql_errors: Vec<GraphqlError> = serde_json::from_value(errors.clone())?;
124            return Err(WaveError::GraphQL(gql_errors));
125        }
126
127        resp.get("data")
128            .cloned()
129            .ok_or_else(|| WaveError::Auth("missing data after retry".into()))
130    }
131
132    /// Execute a mutation and handle the `didSucceed` / `inputErrors` pattern.
133    pub(crate) async fn execute_mutation<T: DeserializeOwned>(
134        &self,
135        query: &str,
136        variables: serde_json::Value,
137        result_key: &str,
138    ) -> Result<T, WaveError> {
139        let data = self.execute(query, variables).await?;
140
141        let result = data
142            .get(result_key)
143            .ok_or_else(|| WaveError::Auth(format!("missing key '{result_key}' in response")))?;
144
145        let did_succeed = result
146            .get("didSucceed")
147            .and_then(|v| v.as_bool())
148            .unwrap_or(false);
149
150        if !did_succeed {
151            if let Some(errors) = result.get("inputErrors") {
152                let input_errors: Vec<InputError> = serde_json::from_value(errors.clone())?;
153                if !input_errors.is_empty() {
154                    return Err(WaveError::MutationFailed(input_errors));
155                }
156            }
157            return Err(WaveError::MutationFailed(vec![]));
158        }
159
160        let parsed: T = serde_json::from_value(result.clone())?;
161        Ok(parsed)
162    }
163}