Skip to main content

supabase_client_graphql/
client.rs

1use std::sync::{Arc, RwLock};
2
3use reqwest::header::{HeaderMap, HeaderValue};
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6use tracing::debug;
7use url::Url;
8
9use crate::error::GraphqlError;
10use crate::mutation::{MutationBuilder, MutationKind as BuilderMutationKind};
11use crate::query::QueryBuilder;
12use crate::types::GraphqlResponse;
13
14/// HTTP client for the Supabase GraphQL endpoint (`/graphql/v1`).
15///
16/// Provides both raw query execution and fluent builder methods for
17/// collection queries and mutations.
18///
19/// # Example
20/// ```ignore
21/// use supabase_client_graphql::GraphqlClient;
22///
23/// let client = GraphqlClient::new("https://your-project.supabase.co", "your-anon-key")?;
24///
25/// // Raw query
26/// let response = client.execute("query { blogCollection { edges { node { id } } } }", None, None).await?;
27///
28/// // Builder API
29/// let connection = client.collection("blogCollection")
30///     .select(&["id", "title"])
31///     .first(10)
32///     .execute::<BlogRow>().await?;
33/// ```
34#[derive(Debug, Clone)]
35pub struct GraphqlClient {
36    http: reqwest::Client,
37    base_url: Url,
38    api_key: String,
39    /// Overridden auth token (if set via `set_auth`).
40    auth_override: Arc<RwLock<Option<String>>>,
41}
42
43impl GraphqlClient {
44    /// Create a new GraphQL client.
45    ///
46    /// `supabase_url` is the project URL (e.g., `https://your-project.supabase.co`).
47    /// `api_key` is the Supabase anon or service_role key.
48    pub fn new(supabase_url: &str, api_key: &str) -> Result<Self, GraphqlError> {
49        let base = supabase_url.trim_end_matches('/');
50        let base_url = Url::parse(&format!("{}/graphql/v1", base))?;
51
52        let mut default_headers = HeaderMap::new();
53        default_headers.insert(
54            "apikey",
55            HeaderValue::from_str(api_key).map_err(|e| {
56                GraphqlError::InvalidConfig(format!("Invalid API key header: {}", e))
57            })?,
58        );
59        default_headers.insert(
60            reqwest::header::AUTHORIZATION,
61            HeaderValue::from_str(&format!("Bearer {}", api_key)).map_err(|e| {
62                GraphqlError::InvalidConfig(format!("Invalid auth header: {}", e))
63            })?,
64        );
65        default_headers.insert(
66            reqwest::header::CONTENT_TYPE,
67            HeaderValue::from_static("application/json"),
68        );
69
70        let http = reqwest::Client::builder()
71            .default_headers(default_headers)
72            .build()
73            .map_err(GraphqlError::Http)?;
74
75        Ok(Self {
76            http,
77            base_url,
78            api_key: api_key.to_string(),
79            auth_override: Arc::new(RwLock::new(None)),
80        })
81    }
82
83    /// Get the GraphQL endpoint URL.
84    pub fn base_url(&self) -> &Url {
85        &self.base_url
86    }
87
88    /// Get the API key used by this client.
89    pub fn api_key(&self) -> &str {
90        &self.api_key
91    }
92
93    /// Update the default auth token for GraphQL requests.
94    ///
95    /// Subsequent requests will use `Bearer <token>` instead of the API key.
96    pub fn set_auth(&self, token: &str) {
97        let mut auth = self.auth_override.write().unwrap();
98        *auth = Some(token.to_string());
99    }
100
101    /// Execute a raw GraphQL query/mutation.
102    ///
103    /// # Arguments
104    /// * `query` - The GraphQL query or mutation string.
105    /// * `variables` - Optional variables as a JSON value.
106    /// * `operation_name` - Optional operation name.
107    ///
108    /// # Returns
109    /// The parsed `GraphqlResponse<T>` where `T` is the shape of the `data` field.
110    pub async fn execute<T: DeserializeOwned>(
111        &self,
112        query: &str,
113        variables: Option<Value>,
114        operation_name: Option<&str>,
115    ) -> Result<GraphqlResponse<T>, GraphqlError> {
116        let mut body = serde_json::json!({ "query": query });
117
118        if let Some(vars) = variables {
119            body["variables"] = vars;
120        }
121        if let Some(op) = operation_name {
122            body["operationName"] = Value::String(op.to_string());
123        }
124
125        debug!(query = query, "Executing GraphQL query");
126
127        let mut request = self.http.post(self.base_url.as_str()).json(&body);
128
129        // Apply auth override if set
130        if let Some(ref token) = *self.auth_override.read().unwrap() {
131            request = request.header(
132                reqwest::header::AUTHORIZATION,
133                HeaderValue::from_str(&format!("Bearer {}", token)).map_err(|e| {
134                    GraphqlError::InvalidConfig(format!("Invalid auth override header: {}", e))
135                })?,
136            );
137        }
138
139        let response = request.send().await?;
140        let status = response.status().as_u16();
141
142        if status >= 400 {
143            let body_text = response.text().await.unwrap_or_default();
144            debug!(status, body = %body_text, "GraphQL HTTP error");
145            return Err(GraphqlError::HttpError {
146                status,
147                message: body_text,
148            });
149        }
150
151        let gql_response: GraphqlResponse<T> = response.json().await?;
152
153        // If there are errors and no data, return them as an error
154        if !gql_response.errors.is_empty() && gql_response.data.is_none() {
155            return Err(GraphqlError::GraphqlErrors(gql_response.errors));
156        }
157
158        Ok(gql_response)
159    }
160
161    /// Execute a raw GraphQL query and return the full `data` JSON value.
162    ///
163    /// This is a convenience wrapper around [`execute`](Self::execute) that returns
164    /// the data as an untyped `serde_json::Value`.
165    pub async fn execute_raw(
166        &self,
167        query: &str,
168        variables: Option<Value>,
169        operation_name: Option<&str>,
170    ) -> Result<GraphqlResponse<Value>, GraphqlError> {
171        self.execute(query, variables, operation_name).await
172    }
173
174    /// Start building a collection query.
175    ///
176    /// # Example
177    /// ```ignore
178    /// let connection = client.collection("blogCollection")
179    ///     .select(&["id", "title"])
180    ///     .first(10)
181    ///     .execute::<BlogRow>().await?;
182    /// ```
183    pub fn collection(&self, name: &str) -> QueryBuilder {
184        QueryBuilder::new(self.clone(), name.to_string())
185    }
186
187    /// Start building an insert mutation.
188    ///
189    /// # Example
190    /// ```ignore
191    /// let result = client.insert_into("blogCollection")
192    ///     .objects(vec![json!({"title": "New Post"})])
193    ///     .returning(&["id", "title"])
194    ///     .execute::<BlogRow>().await?;
195    /// ```
196    pub fn insert_into(&self, collection: &str) -> MutationBuilder {
197        MutationBuilder::new(
198            self.clone(),
199            collection.to_string(),
200            BuilderMutationKind::Insert,
201        )
202    }
203
204    /// Start building an update mutation.
205    ///
206    /// # Example
207    /// ```ignore
208    /// let result = client.update("blogCollection")
209    ///     .set(json!({"title": "Updated"}))
210    ///     .filter(GqlFilter::eq("id", 1))
211    ///     .at_most(1)
212    ///     .returning(&["id", "title"])
213    ///     .execute::<BlogRow>().await?;
214    /// ```
215    pub fn update(&self, collection: &str) -> MutationBuilder {
216        MutationBuilder::new(
217            self.clone(),
218            collection.to_string(),
219            BuilderMutationKind::Update,
220        )
221    }
222
223    /// Start building a delete mutation.
224    ///
225    /// # Example
226    /// ```ignore
227    /// let result = client.delete_from("blogCollection")
228    ///     .filter(GqlFilter::eq("id", 1))
229    ///     .at_most(1)
230    ///     .returning(&["id"])
231    ///     .execute::<BlogRow>().await?;
232    /// ```
233    pub fn delete_from(&self, collection: &str) -> MutationBuilder {
234        MutationBuilder::new(
235            self.clone(),
236            collection.to_string(),
237            BuilderMutationKind::Delete,
238        )
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn client_new_ok() {
248        let client = GraphqlClient::new("https://example.supabase.co", "test-key");
249        assert!(client.is_ok());
250    }
251
252    #[test]
253    fn client_base_url() {
254        let client = GraphqlClient::new("https://example.supabase.co", "test-key").unwrap();
255        assert_eq!(client.base_url().path(), "/graphql/v1");
256    }
257
258    #[test]
259    fn client_base_url_trailing_slash() {
260        let client = GraphqlClient::new("https://example.supabase.co/", "test-key").unwrap();
261        assert_eq!(client.base_url().path(), "/graphql/v1");
262    }
263
264    #[test]
265    fn client_api_key() {
266        let client = GraphqlClient::new("https://example.supabase.co", "my-key").unwrap();
267        assert_eq!(client.api_key(), "my-key");
268    }
269
270    #[test]
271    fn set_auth_updates_override() {
272        let client = GraphqlClient::new("https://example.supabase.co", "test-key").unwrap();
273        assert!(client.auth_override.read().unwrap().is_none());
274        client.set_auth("new-token");
275        assert_eq!(
276            client.auth_override.read().unwrap().as_deref(),
277            Some("new-token")
278        );
279    }
280
281    #[test]
282    fn set_auth_clone_shares_state() {
283        let client = GraphqlClient::new("https://example.supabase.co", "test-key").unwrap();
284        let clone = client.clone();
285        client.set_auth("shared-token");
286        assert_eq!(
287            clone.auth_override.read().unwrap().as_deref(),
288            Some("shared-token")
289        );
290    }
291}