From 086d879f652d20dc17df6c142130f8b8a68bf1e6 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Wed, 28 Jan 2026 20:34:14 -0500 Subject: [PATCH] Refactor codebase for idiomatic Rust Improvements: - Remove unnecessary braces in imports - Remove redundant type annotations throughout - Use derive(Clone) instead of manual implementation for Claims - Remove async from jwt_valid (no await needed) - Remove debug println! statements from production code - Fix logout() to return proper error instead of panicking - Add LogoutError type for missing bearer token - Add DbError enum for database operation errors - Make insert/update return Result to handle serialization errors - Use impl Into for flexible API in filter methods - Add set_bearer_token() method for cleaner token setting - Rename structs for clarity (Password -> Credentials) - Use &str in request structs with serde for efficiency - Add helper method add_filter() to reduce code duplication - Improve doc comments with proper /// format - Use modern Rust patterns (let-else, format string interpolation) - Add more comprehensive tests Co-Authored-By: Claude Opus 4.5 --- src/auth.rs | 273 +++++++++++++++++++----------------------- src/client.rs | 72 +++++++---- src/db.rs | 326 ++++++++++++++++++++++++++++---------------------- src/lib.rs | 4 +- 4 files changed, 352 insertions(+), 323 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index e1c9bda..d90a4e1 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,128 +1,114 @@ -use jsonwebtoken::{DecodingKey, Validation, Algorithm, decode}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use reqwest::{Error, Response}; use serde::{Deserialize, Serialize}; use crate::Supabase; -#[derive(Serialize, Deserialize)] -pub struct Password { - email: String, - password: String, +#[derive(Serialize)] +struct Credentials<'a> { + email: &'a str, + password: &'a str, } -#[derive(Serialize, Deserialize)] -pub struct RefreshToken { - refresh_token: String, +#[derive(Serialize)] +struct RefreshTokenRequest<'a> { + refresh_token: &'a str, } -#[derive(Debug, Serialize, Deserialize)] +/// JWT claims extracted from a valid token. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { pub sub: String, pub email: String, pub exp: usize, } -impl Clone for Claims { - fn clone(&self) -> Self { - Self { - sub: self.sub.clone(), - email: self.email.clone(), - exp: self.exp, - } +/// Error returned when logout fails due to missing bearer token. +#[derive(Debug)] +pub struct LogoutError; + +impl std::fmt::Display for LogoutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "bearer token required for logout") } } -impl Supabase { - pub async fn jwt_valid( - &self, - jwt: &str, - ) -> Result { - let secret = self.jwt.clone(); +impl std::error::Error for LogoutError {} - let decoding_key = DecodingKey::from_secret(secret.as_ref()).into(); +impl Supabase { + /// Validates a JWT token and returns its claims. + /// + /// Returns an error if the token is invalid or expired. + pub fn jwt_valid(&self, jwt: &str) -> Result { + let decoding_key = DecodingKey::from_secret(self.jwt.as_bytes()); let validation = Validation::new(Algorithm::HS256); - let decoded_token = decode::(&jwt, &decoding_key, &validation); - - match decoded_token { - Ok(token_data) => { - println!("Token is valid. Claims: {:?}", token_data.claims); - Ok(token_data.claims) - } - Err(err) => { - println!("Error decoding token: {:?}", err); - Err(err) - } - } + let token_data = decode::(jwt, &decoding_key, &validation)?; + Ok(token_data.claims) } - pub async fn sign_in_password( - &self, - email: &str, - password: &str, - ) -> Result { - let request_url: String = format!("{}/auth/v1/token?grant_type=password", self.url); - let response: Response = self - .client - .post(&request_url) + /// Signs in a user with email and password. + /// + /// Returns the response containing access and refresh tokens. + pub async fn sign_in_password(&self, email: &str, password: &str) -> Result { + let url = format!("{}/auth/v1/token?grant_type=password", self.url); + + self.client + .post(&url) .header("apikey", &self.api_key) .header("Content-Type", "application/json") - .json(&Password { - email: email.to_string(), - password: password.to_string(), - }) + .json(&Credentials { email, password }) .send() - .await?; - Ok(response) + .await } - // This test will fail unless you disable "Enable automatic reuse detection" in Supabase + /// Refreshes an access token using a refresh token. + /// + /// Note: This may fail if "Enable automatic reuse detection" is enabled in Supabase. pub async fn refresh_token(&self, refresh_token: &str) -> Result { - let request_url: String = format!("{}/auth/v1/token?grant_type=refresh_token", self.url); - let response: Response = self - .client - .post(&request_url) + let url = format!("{}/auth/v1/token?grant_type=refresh_token", self.url); + + self.client + .post(&url) .header("apikey", &self.api_key) .header("Content-Type", "application/json") - .json(&RefreshToken { - refresh_token: refresh_token.to_string(), - }) + .json(&RefreshTokenRequest { refresh_token }) .send() - .await?; - Ok(response) + .await } - pub async fn logout(&self) -> Result { - let request_url: String = format!("{}/auth/v1/logout", self.url); - let token = self.bearer_token.clone().unwrap(); - let response: Response = self + /// Logs out the current user. + /// + /// Requires a bearer token to be set on the client. + /// Returns `Err(LogoutError)` if no bearer token is set. + pub async fn logout(&self) -> Result, LogoutError> { + let token = self.bearer_token.as_ref().ok_or(LogoutError)?; + let url = format!("{}/auth/v1/logout", self.url); + + Ok(self .client - .post(&request_url) + .post(&url) .header("apikey", &self.api_key) .header("Content-Type", "application/json") .bearer_auth(token) .send() - .await?; - Ok(response) + .await) } + /// Signs up a new user with email and password. pub async fn signup_email_password( &self, email: &str, password: &str, ) -> Result { - let request_url: String = format!("{}/auth/v1/signup", self.url); - let response: Response = self - .client - .post(&request_url) + let url = format!("{}/auth/v1/signup", self.url); + + self.client + .post(&url) .header("apikey", &self.api_key) .header("Content-Type", "application/json") - .json(&Password { - email: email.to_string(), - password: password.to_string(), - }) + .json(&Credentials { email, password }) .send() - .await?; - Ok(response) + .await } } @@ -130,15 +116,14 @@ impl Supabase { mod tests { use super::*; - async fn client() -> Supabase { + fn client() -> Supabase { Supabase::new(None, None, None) } async fn sign_in_password() -> Result { - let client: Supabase = client().await; - - let test_email: String = std::env::var("SUPABASE_TEST_EMAIL").unwrap_or_else(|_| String::new()); - let test_pass: String = std::env::var("SUPABASE_TEST_PASS").unwrap_or_else(|_| String::new()); + let client = client(); + let test_email = std::env::var("SUPABASE_TEST_EMAIL").unwrap_or_default(); + let test_pass = std::env::var("SUPABASE_TEST_PASS").unwrap_or_default(); client.sign_in_password(&test_email, &test_pass).await } @@ -147,22 +132,24 @@ mod tests { let response = match sign_in_password().await { Ok(resp) => resp, Err(e) => { - println!("Test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); return; } }; - let json_response: serde_json::Value = response.json().await.unwrap(); - let token = json_response["access_token"].as_str(); - let refresh_token = json_response["refresh_token"].as_str(); + let json: serde_json::Value = response.json().await.unwrap(); - if token.is_none() || refresh_token.is_none() { + let Some(token) = json["access_token"].as_str() else { println!("Test skipped: invalid credentials or server response"); return; - } + }; + let Some(refresh) = json["refresh_token"].as_str() else { + println!("Test skipped: invalid credentials or server response"); + return; + }; - assert!(!token.unwrap().is_empty()); - assert!(!refresh_token.unwrap().is_empty()); + assert!(!token.is_empty()); + assert!(!refresh.is_empty()); } #[tokio::test] @@ -170,40 +157,34 @@ mod tests { let response = match sign_in_password().await { Ok(resp) => resp, Err(e) => { - println!("Test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); return; } }; - let json_response: serde_json::Value = response.json().await.unwrap(); - let refresh_token = match json_response["refresh_token"].as_str() { - Some(t) => t, - None => { - println!("Test skipped: no refresh token in response"); - return; - } + let json: serde_json::Value = response.json().await.unwrap(); + let Some(refresh_token) = json["refresh_token"].as_str() else { + println!("Test skipped: no refresh token in response"); + return; }; - let response = match client().await.refresh_token(refresh_token).await { + let response = match client().refresh_token(refresh_token).await { Ok(resp) => resp, Err(e) => { - println!("Test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); return; } }; if response.status() == 400 { - println!("Skipping test_refresh() because automatic reuse detection is enabled in Supabase"); + println!("Skipping: automatic reuse detection is enabled"); return; } - let json_response: serde_json::Value = response.json().await.unwrap(); - let token = match json_response["access_token"].as_str() { - Some(t) => t, - None => { - println!("Test skipped: no access token in refresh response"); - return; - } + let json: serde_json::Value = response.json().await.unwrap(); + let Some(token) = json["access_token"].as_str() else { + println!("Test skipped: no access token in refresh response"); + return; }; assert!(!token.is_empty()); @@ -214,39 +195,41 @@ mod tests { let response = match sign_in_password().await { Ok(resp) => resp, Err(e) => { - println!("Test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); return; } }; - let json_response: serde_json::Value = response.json().await.unwrap(); - let access_token = match json_response["access_token"].as_str() { - Some(t) => t, - None => { - println!("Test skipped: no access token in response"); - return; - } + let json: serde_json::Value = response.json().await.unwrap(); + let Some(access_token) = json["access_token"].as_str() else { + println!("Test skipped: no access token in response"); + return; }; - let mut client: Supabase = client().await; - client.bearer_token = Some(access_token.to_string()); + let mut client = client(); + client.set_bearer_token(access_token); let response = match client.logout().await { - Ok(resp) => resp, + Ok(Ok(resp)) => resp, + Ok(Err(e)) => { + println!("Test skipped due to network error: {e}"); + return; + } Err(e) => { - println!("Test skipped due to network error: {}", e); + println!("Test skipped: {e}"); return; } }; - assert!(response.status() == 204); + assert_eq!(response.status(), 204); } #[tokio::test] async fn test_signup_email_password() { - use rand::{thread_rng, Rng, distributions::Alphanumeric}; + use rand::distributions::Alphanumeric; + use rand::{thread_rng, Rng}; - let client: Supabase = client().await; + let client = client(); let rand_string: String = thread_rng() .sample_iter(&Alphanumeric) @@ -254,53 +237,43 @@ mod tests { .map(char::from) .collect(); - let random_email: String = format!("{}@a-rust-domain-that-does-not-exist.com", rand_string); - let random_pass: String = rand_string; + let email = format!("{rand_string}@a-rust-domain-that-does-not-exist.com"); - let test_email: String = random_email; - let test_pass: String = random_pass; - - let response = match client.signup_email_password(&test_email, &test_pass).await { + let response = match client.signup_email_password(&email, &rand_string).await { Ok(resp) => resp, Err(e) => { - println!("Test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); return; } }; - assert!(response.status() == 200); + assert_eq!(response.status(), 200); } #[tokio::test] async fn test_authenticate_token() { - let client: Supabase = client().await; + let client = client(); let response = match sign_in_password().await { Ok(resp) => resp, Err(e) => { - println!("Test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); return; } }; - let json_response: serde_json::Value = response.json().await.unwrap(); - let token = match json_response["access_token"].as_str() { - Some(t) => t, - None => { - println!("Test skipped: no access token in response"); - return; - } + let json: serde_json::Value = response.json().await.unwrap(); + let Some(token) = json["access_token"].as_str() else { + println!("Test skipped: no access token in response"); + return; }; - let response = client.jwt_valid(token).await; + assert!(client.jwt_valid(token).is_ok()); + } - match response { - Ok(_) => { - assert!(true); - }, - Err(_) => { - assert!(false); - } - } + #[test] + fn test_logout_requires_bearer_token() { + // Verify the error type displays correctly + assert_eq!(format!("{}", LogoutError), "bearer token required for logout"); } -} \ No newline at end of file +} diff --git a/src/client.rs b/src/client.rs index 8e22056..c7ef4a2 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,31 +1,34 @@ -use std::{env}; -use reqwest::{Client}; +use std::env; + +use reqwest::Client; use crate::Supabase; impl Supabase { - // Creates a new Supabase client. If no parameters are provided, it will attempt to read the - // environment variables `SUPABASE_URL`, `SUPABASE_API_KEY`, and `SUPABASE_JWT_SECRET`. + /// Creates a new Supabase client. + /// + /// If no parameters are provided, it will attempt to read from environment + /// variables: `SUPABASE_URL`, `SUPABASE_API_KEY`, and `SUPABASE_JWT_SECRET`. pub fn new(url: Option<&str>, api_key: Option<&str>, jwt: Option<&str>) -> Self { - let client: Client = Client::new(); - let url: String = url - .map(String::from) - .unwrap_or_else(|| env::var("SUPABASE_URL").unwrap_or_else(|_| String::new())); - let api_key: String = api_key - .map(String::from) - .unwrap_or_else(|| env::var("SUPABASE_API_KEY").unwrap_or_else(|_| String::new())); - let jwt: String = jwt - .map(String::from) - .unwrap_or_else(|| env::var("SUPABASE_JWT_SECRET").unwrap_or_else(|_| String::new())); - - Supabase { - client, - url: url.to_string(), - api_key: api_key.to_string(), - jwt: jwt.to_string(), + Self { + client: Client::new(), + url: url + .map(Into::into) + .unwrap_or_else(|| env::var("SUPABASE_URL").unwrap_or_default()), + api_key: api_key + .map(Into::into) + .unwrap_or_else(|| env::var("SUPABASE_API_KEY").unwrap_or_default()), + jwt: jwt + .map(Into::into) + .unwrap_or_else(|| env::var("SUPABASE_JWT_SECRET").unwrap_or_default()), bearer_token: None, } } + + /// Sets the bearer token for authenticated requests. + pub fn set_bearer_token(&mut self, token: impl Into) { + self.bearer_token = Some(token.into()); + } } #[cfg(test)] @@ -33,9 +36,28 @@ mod tests { use super::*; #[test] - fn test_client() { - let client: Supabase = Supabase::new(None, None, None); - let url = client.url.clone(); - assert!(client.url == url); + fn test_client_creation() { + let client = Supabase::new( + Some("https://example.supabase.co"), + Some("test-key"), + Some("test-jwt"), + ); + assert_eq!(client.url, "https://example.supabase.co"); + assert_eq!(client.api_key, "test-key"); + assert_eq!(client.jwt, "test-jwt"); + } + + #[test] + fn test_client_from_env() { + // When env vars are not set, fields should be empty + let client = Supabase::new(None, None, None); + assert!(client.bearer_token.is_none()); } -} \ No newline at end of file + + #[test] + fn test_set_bearer_token() { + let mut client = Supabase::new(None, None, None); + client.set_bearer_token("my-token"); + assert_eq!(client.bearer_token, Some("my-token".to_string())); + } +} diff --git a/src/db.rs b/src/db.rs index 87f88c3..ba326f6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,7 +3,47 @@ use serde::Serialize; use crate::Supabase; +/// Error type for database operations. +#[derive(Debug)] +pub enum DbError { + /// Failed to serialize data to JSON. + Serialization(serde_json::Error), + /// HTTP request failed. + Request(Error), +} + +impl std::fmt::Display for DbError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Serialization(e) => write!(f, "serialization error: {e}"), + Self::Request(e) => write!(f, "request error: {e}"), + } + } +} + +impl std::error::Error for DbError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Serialization(e) => Some(e), + Self::Request(e) => Some(e), + } + } +} + +impl From for DbError { + fn from(err: serde_json::Error) -> Self { + Self::Serialization(err) + } +} + +impl From for DbError { + fn from(err: Error) -> Self { + Self::Request(err) + } +} + /// Query builder for PostgREST database operations. +/// /// Provides a fluent API for constructing and executing database queries. pub struct QueryBuilder<'a> { client: &'a Supabase, @@ -15,10 +55,10 @@ pub struct QueryBuilder<'a> { impl<'a> QueryBuilder<'a> { /// Creates a new QueryBuilder for the specified table. - pub fn new(client: &'a Supabase, table: &str) -> Self { - QueryBuilder { + pub(crate) fn new(client: &'a Supabase, table: impl Into) -> Self { + Self { client, - table: table.to_string(), + table: table.into(), query_params: Vec::new(), method: Method::GET, body: None, @@ -26,155 +66,169 @@ impl<'a> QueryBuilder<'a> { } /// Specifies which columns to select. - /// Pass "*" to select all columns, or a comma-separated list of column names. - pub fn select(mut self, columns: &str) -> Self { - self.query_params.push(("select".to_string(), columns.to_string())); + /// + /// Pass `"*"` to select all columns, or a comma-separated list of column names. + pub fn select(mut self, columns: impl Into) -> Self { + self.query_params.push(("select".into(), columns.into())); self.method = Method::GET; self } /// Prepares an insert operation with the provided data. - /// Data will be serialized to JSON. - pub fn insert(mut self, data: &T) -> Self { + /// + /// Data will be serialized to JSON. Call `execute()` to run the query. + pub fn insert(mut self, data: &T) -> Result { self.method = Method::POST; - self.body = Some(serde_json::to_string(data).unwrap()); - self + self.body = Some(serde_json::to_string(data)?); + Ok(self) } /// Prepares an update operation with the provided data. + /// /// Should be combined with filter methods to target specific rows. - pub fn update(mut self, data: &T) -> Self { + pub fn update(mut self, data: &T) -> Result { self.method = Method::PATCH; - self.body = Some(serde_json::to_string(data).unwrap()); - self + self.body = Some(serde_json::to_string(data)?); + Ok(self) } /// Prepares a delete operation. + /// /// Should be combined with filter methods to target specific rows. pub fn delete(mut self) -> Self { self.method = Method::DELETE; self } - /// Filter: column equals value (col=eq.val) - pub fn eq(mut self, column: &str, value: &str) -> Self { - self.query_params.push((column.to_string(), format!("eq.{}", value))); - self - } - - /// Filter: column not equals value (col=neq.val) - pub fn neq(mut self, column: &str, value: &str) -> Self { - self.query_params.push((column.to_string(), format!("neq.{}", value))); - self + /// Filter: column equals value (`col=eq.val`). + pub fn eq(self, column: impl Into, value: impl Into) -> Self { + self.add_filter(column, "eq", value) } - /// Filter: column greater than value (col=gt.val) - pub fn gt(mut self, column: &str, value: &str) -> Self { - self.query_params.push((column.to_string(), format!("gt.{}", value))); - self + /// Filter: column not equals value (`col=neq.val`). + pub fn neq(self, column: impl Into, value: impl Into) -> Self { + self.add_filter(column, "neq", value) } - /// Filter: column greater than or equal to value (col=gte.val) - pub fn gte(mut self, column: &str, value: &str) -> Self { - self.query_params.push((column.to_string(), format!("gte.{}", value))); - self + /// Filter: column greater than value (`col=gt.val`). + pub fn gt(self, column: impl Into, value: impl Into) -> Self { + self.add_filter(column, "gt", value) } - /// Filter: column less than value (col=lt.val) - pub fn lt(mut self, column: &str, value: &str) -> Self { - self.query_params.push((column.to_string(), format!("lt.{}", value))); - self + /// Filter: column greater than or equal to value (`col=gte.val`). + pub fn gte(self, column: impl Into, value: impl Into) -> Self { + self.add_filter(column, "gte", value) } - /// Filter: column less than or equal to value (col=lte.val) - pub fn lte(mut self, column: &str, value: &str) -> Self { - self.query_params.push((column.to_string(), format!("lte.{}", value))); - self + /// Filter: column less than value (`col=lt.val`). + pub fn lt(self, column: impl Into, value: impl Into) -> Self { + self.add_filter(column, "lt", value) } - /// Filter: column matches pattern (col=like.pattern) - /// Use * as wildcard character. - pub fn like(mut self, column: &str, pattern: &str) -> Self { - self.query_params.push((column.to_string(), format!("like.{}", pattern))); - self + /// Filter: column less than or equal to value (`col=lte.val`). + pub fn lte(self, column: impl Into, value: impl Into) -> Self { + self.add_filter(column, "lte", value) } - /// Filter: column matches pattern case-insensitively (col=ilike.pattern) - /// Use * as wildcard character. - pub fn ilike(mut self, column: &str, pattern: &str) -> Self { - self.query_params.push((column.to_string(), format!("ilike.{}", pattern))); - self + /// Filter: column matches pattern (`col=like.pattern`). + /// + /// Use `*` as wildcard character. + pub fn like(self, column: impl Into, pattern: impl Into) -> Self { + self.add_filter(column, "like", pattern) } - /// Filter: column value is in the provided list (col=in.(v1,v2,...)) - pub fn in_(mut self, column: &str, values: &[&str]) -> Self { - let values_str = values.join(","); - self.query_params.push((column.to_string(), format!("in.({})", values_str))); + /// Filter: column matches pattern case-insensitively (`col=ilike.pattern`). + /// + /// Use `*` as wildcard character. + pub fn ilike(self, column: impl Into, pattern: impl Into) -> Self { + self.add_filter(column, "ilike", pattern) + } + + /// Filter: column value is in the provided list (`col=in.(v1,v2,...)`). + pub fn in_(mut self, column: impl Into, values: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let values_str: Vec<_> = values.into_iter().map(|s| s.as_ref().to_string()).collect(); + self.query_params + .push((column.into(), format!("in.({})", values_str.join(",")))); self } - /// Filter: column is null (col=is.null) - pub fn is_null(mut self, column: &str) -> Self { - self.query_params.push((column.to_string(), "is.null".to_string())); + /// Filter: column is null (`col=is.null`). + pub fn is_null(mut self, column: impl Into) -> Self { + self.query_params.push((column.into(), "is.null".into())); self } - /// Filter: column is not null (col=not.is.null) - pub fn not_null(mut self, column: &str) -> Self { - self.query_params.push((column.to_string(), "not.is.null".to_string())); + /// Filter: column is not null (`col=not.is.null`). + pub fn not_null(mut self, column: impl Into) -> Self { + self.query_params.push((column.into(), "not.is.null".into())); self } /// Orders results by the specified column. - /// Use "column" for ascending or "column.desc" for descending. - pub fn order(mut self, column: &str) -> Self { - self.query_params.push(("order".to_string(), column.to_string())); + /// + /// Use `"column"` for ascending or `"column.desc"` for descending. + pub fn order(mut self, column: impl Into) -> Self { + self.query_params.push(("order".into(), column.into())); self } /// Limits the number of rows returned. pub fn limit(mut self, count: usize) -> Self { - self.query_params.push(("limit".to_string(), count.to_string())); + self.query_params.push(("limit".into(), count.to_string())); self } /// Offsets the results by the specified number of rows. pub fn offset(mut self, count: usize) -> Self { - self.query_params.push(("offset".to_string(), count.to_string())); + self.query_params.push(("offset".into(), count.to_string())); self } /// Executes the query and returns the response. pub async fn execute(self) -> Result { - let request_url = format!("{}/rest/v1/{}", self.client.url, self.table); - - let mut request = self.client.client.request(self.method, &request_url); + let url = format!("{}/rest/v1/{}", self.client.url, self.table); - // Add standard headers - request = request.header("apikey", &self.client.api_key); - request = request.header("Content-Type", "application/json"); + let mut request = self + .client + .client + .request(self.method, &url) + .header("apikey", &self.client.api_key) + .header("Content-Type", "application/json"); - // Add bearer token if available if let Some(ref token) = self.client.bearer_token { request = request.bearer_auth(token); } - // Add query parameters if !self.query_params.is_empty() { request = request.query(&self.query_params); } - // Add body if present if let Some(body) = self.body { request = request.body(body); } request.send().await } + + fn add_filter( + mut self, + column: impl Into, + op: &str, + value: impl Into, + ) -> Self { + self.query_params + .push((column.into(), format!("{op}.{}", value.into()))); + self + } } impl Supabase { /// Creates a QueryBuilder for the specified table. + /// /// This is the entry point for all database operations. /// /// # Examples @@ -187,15 +241,15 @@ impl Supabase { /// client.from("users").select("id,name").eq("status", "active").execute().await?; /// /// // Insert - /// client.from("users").insert(&user_data).execute().await?; + /// client.from("users").insert(&user_data)?.execute().await?; /// /// // Update - /// client.from("users").update(&updates).eq("id", "123").execute().await?; + /// client.from("users").update(&updates)?.eq("id", "123").execute().await?; /// /// // Delete /// client.from("users").delete().eq("id", "123").execute().await?; /// ``` - pub fn from(&self, table: &str) -> QueryBuilder { + pub fn from(&self, table: impl Into) -> QueryBuilder { QueryBuilder::new(self, table) } } @@ -205,7 +259,7 @@ mod tests { use super::*; use serde::{Deserialize, Serialize}; - async fn client() -> Supabase { + fn client() -> Supabase { Supabase::new(None, None, None) } @@ -215,166 +269,141 @@ mod tests { value: i32, } - #[derive(Debug, Serialize, Deserialize)] - struct TestItemWithId { - id: i64, - name: String, - value: i32, - } - #[tokio::test] async fn test_select() { - let client = client().await; + let client = client(); - // This test requires a 'test_items' table in Supabase - let response = client - .from("test_items") - .select("*") - .execute() - .await; + let result = client.from("test_items").select("*").execute().await; - match response { + match result { Ok(resp) => { - // If we get a response, check status (might be 200 or 401 depending on auth) let status = resp.status(); - println!("Select response status: {}", status); assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - // Network error is acceptable in test environment - println!("Select test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } #[tokio::test] async fn test_select_columns() { - let client = client().await; + let client = client(); - let response = client - .from("test_items") - .select("id,name") - .execute() - .await; + let result = client.from("test_items").select("id,name").execute().await; - match response { + match result { Ok(resp) => { let status = resp.status(); - println!("Select columns response status: {}", status); assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - println!("Select columns test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } #[tokio::test] async fn test_select_with_filter() { - let client = client().await; + let client = client(); - let response = client + let result = client .from("test_items") .select("*") .eq("name", "test") .execute() .await; - match response { + match result { Ok(resp) => { let status = resp.status(); - println!("Select with filter response status: {}", status); assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - println!("Select with filter test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } #[tokio::test] async fn test_insert() { - let client = client().await; + let client = client(); let item = TestItem { - name: "test_item".to_string(), + name: "test_item".into(), value: 42, }; - let response = client + let result = client .from("test_items") .insert(&item) + .expect("serialization should succeed") .execute() .await; - match response { + match result { Ok(resp) => { let status = resp.status(); - println!("Insert response status: {}", status); - // 201 Created, 200 OK, or 401 Unauthorized (if auth required) assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - println!("Insert test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } #[tokio::test] async fn test_update() { - let client = client().await; + let client = client(); - let updates = serde_json::json!({ - "value": 100 - }); + let updates = serde_json::json!({ "value": 100 }); - let response = client + let result = client .from("test_items") .update(&updates) + .expect("serialization should succeed") .eq("name", "test_item") .execute() .await; - match response { + match result { Ok(resp) => { let status = resp.status(); - println!("Update response status: {}", status); assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - println!("Update test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } #[tokio::test] async fn test_delete() { - let client = client().await; + let client = client(); - let response = client + let result = client .from("test_items") .delete() .eq("name", "test_item") .execute() .await; - match response { + match result { Ok(resp) => { let status = resp.status(); - println!("Delete response status: {}", status); assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - println!("Delete test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } #[tokio::test] async fn test_select_with_order_and_limit() { - let client = client().await; + let client = client(); - let response = client + let result = client .from("test_items") .select("*") .order("id.desc") @@ -382,23 +411,22 @@ mod tests { .execute() .await; - match response { + match result { Ok(resp) => { let status = resp.status(); - println!("Select with order/limit response status: {}", status); assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - println!("Select with order/limit test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } #[tokio::test] async fn test_select_with_multiple_filters() { - let client = client().await; + let client = client(); - let response = client + let result = client .from("test_items") .select("*") .gte("value", "10") @@ -406,38 +434,44 @@ mod tests { .execute() .await; - match response { + match result { Ok(resp) => { let status = resp.status(); - println!("Select with multiple filters response status: {}", status); assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - println!("Select with multiple filters test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } #[tokio::test] async fn test_in_filter() { - let client = client().await; + let client = client(); - let response = client + let result = client .from("test_items") .select("*") - .in_("id", &["1", "2", "3"]) + .in_("id", ["1", "2", "3"]) .execute() .await; - match response { + match result { Ok(resp) => { let status = resp.status(); - println!("Select with in filter response status: {}", status); assert!(status.is_success() || status.as_u16() == 401); } Err(e) => { - println!("Select with in filter test skipped due to network error: {}", e); + println!("Test skipped due to network error: {e}"); } } } + + #[test] + fn test_db_error_display() { + // Verify error types display correctly + let json_err = serde_json::from_str::("invalid").unwrap_err(); + let db_err = DbError::Serialization(json_err); + assert!(format!("{db_err}").contains("serialization error")); + } } diff --git a/src/lib.rs b/src/lib.rs index 2d0bd6b..d0e192a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -use reqwest::{Client}; +use reqwest::Client; pub mod auth; mod client; @@ -11,4 +11,4 @@ pub struct Supabase { api_key: String, jwt: String, bearer_token: Option, -} \ No newline at end of file +}