diff --git a/Cargo.toml b/Cargo.toml index 54a7c07..57aafdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,22 @@ [package] name = "supabase-rust" -version = "0.2.0" +version = "0.3.0" authors = ["Kacy Fortner "] edition = "2021" description = "Rust client for Supabase" repository = "https://github.com/kacy/supabase-rust.git" homepage = "https://github.com/kacy/supabase-rust" license = "MIT" +keywords = ["supabase", "postgrest", "auth", "client"] +categories = ["api-bindings", "web-programming::http-client"] [dev-dependencies] rand = "0.9" +tokio = { version = "1", features = ["rt", "macros"] } [dependencies] jsonwebtoken = "9.3" reqwest = { version = "0.12", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -time = "0.3.37" -tokio = { version = "1", features = ["full"] } +thiserror = "2" diff --git a/src/auth.rs b/src/auth.rs index 7ed16ee..2add382 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,5 +1,5 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; -use reqwest::{Error, Response}; +use reqwest::Response; use serde::{Deserialize, Serialize}; use crate::Supabase; @@ -23,17 +23,31 @@ pub struct Claims { pub exp: usize, } -/// 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") - } +/// Response returned by authentication endpoints that issue tokens. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthResponse { + /// The JWT access token. + pub access_token: String, + /// The token type (typically `"bearer"`). + pub token_type: String, + /// Seconds until the access token expires. + pub expires_in: u64, + /// Unix timestamp when the access token expires. + #[serde(default)] + pub expires_at: Option, + /// Token used to obtain a new access token. + pub refresh_token: String, + /// User information, if returned by the endpoint. + #[serde(default)] + pub user: Option, } -impl std::error::Error for LogoutError {} +/// Response for endpoints that return no body on success. +#[derive(Debug, Clone)] +pub struct EmptyResponse { + /// HTTP status code. + pub status: u16, +} #[derive(Serialize)] struct RecoverRequest<'a> { @@ -69,10 +83,51 @@ struct ResendOtpRequest<'a> { } impl Supabase { + /// Sends a POST request to the given auth endpoint path with standard headers. + async fn auth_post( + &self, + path: &str, + body: &impl Serialize, + ) -> Result { + let url = format!("{}/auth/v1/{path}", self.url); + + let resp = self + .client + .post(&url) + .header("apikey", &self.api_key) + .header("Content-Type", "application/json") + .json(body) + .send() + .await?; + + Ok(resp) + } + + /// Checks the response status and deserializes as `AuthResponse`. + async fn parse_auth_response(response: Response) -> Result { + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let message = response.text().await.unwrap_or_default(); + return Err(crate::Error::Api { status, message }); + } + let auth: AuthResponse = response.json().await?; + Ok(auth) + } + + /// Checks the response status and returns an `EmptyResponse`. + async fn parse_empty_response(response: Response) -> Result { + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let message = response.text().await.unwrap_or_default(); + return Err(crate::Error::Api { status, message }); + } + Ok(EmptyResponse { status }) + } + /// 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 { + 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 token_data = decode::(jwt, &decoding_key, &validation)?; @@ -81,63 +136,65 @@ impl Supabase { /// 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(&Credentials { email, password }) - .send() - .await + /// Returns an [`AuthResponse`] containing access and refresh tokens. + pub async fn sign_in_password( + &self, + email: &str, + password: &str, + ) -> Result { + let resp = self + .auth_post( + "token?grant_type=password", + &Credentials { email, password }, + ) + .await?; + Self::parse_auth_response(resp).await } /// 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 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(&RefreshTokenRequest { refresh_token }) - .send() - .await + pub async fn refresh_token( + &self, + refresh_token: &str, + ) -> Result { + let resp = self + .auth_post( + "token?grant_type=refresh_token", + &RefreshTokenRequest { refresh_token }, + ) + .await?; + Self::parse_auth_response(resp).await } /// 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)?; + pub async fn logout(&self) -> Result { + let token = self.bearer_token.as_ref().ok_or_else(|| { + crate::Error::AuthRequired("bearer token required for logout".into()) + })?; let url = format!("{}/auth/v1/logout", self.url); - Ok(self + let resp = self .client .post(&url) .header("apikey", &self.api_key) .header("Content-Type", "application/json") .bearer_auth(token) .send() - .await) + .await?; + + Self::parse_empty_response(resp).await } /// Sends a password recovery email to the given address. - pub async fn recover_password(&self, email: &str) -> Result { - let url = format!("{}/auth/v1/recover", self.url); - - self.client - .post(&url) - .header("apikey", &self.api_key) - .header("Content-Type", "application/json") - .json(&RecoverRequest { email }) - .send() - .await + pub async fn recover_password( + &self, + email: &str, + ) -> Result { + let resp = self.auth_post("recover", &RecoverRequest { email }).await?; + Self::parse_empty_response(resp).await } /// Signs up a new user with phone and password. @@ -145,16 +202,11 @@ impl Supabase { &self, phone: &str, password: &str, - ) -> Result { - let url = format!("{}/auth/v1/signup", self.url); - - self.client - .post(&url) - .header("apikey", &self.api_key) - .header("Content-Type", "application/json") - .json(&PhoneCredentials { phone, password }) - .send() - .await + ) -> Result { + let resp = self + .auth_post("signup", &PhoneCredentials { phone, password }) + .await?; + Self::parse_auth_response(resp).await } /// Sends a one-time password to the given phone number. @@ -164,40 +216,33 @@ impl Supabase { &self, phone: &str, channel: Option<&str>, - ) -> Result { - let url = format!("{}/auth/v1/otp", self.url); - - self.client - .post(&url) - .header("apikey", &self.api_key) - .header("Content-Type", "application/json") - .json(&OtpRequest { phone, channel }) - .send() - .await + ) -> Result { + let resp = self + .auth_post("otp", &OtpRequest { phone, channel }) + .await?; + Self::parse_empty_response(resp).await } /// Verifies a one-time password token. /// - /// Returns access and refresh tokens on success. + /// Returns an [`AuthResponse`] containing access and refresh tokens on success. pub async fn verify_otp( &self, phone: &str, token: &str, verification_type: &str, - ) -> Result { - let url = format!("{}/auth/v1/verify", self.url); - - self.client - .post(&url) - .header("apikey", &self.api_key) - .header("Content-Type", "application/json") - .json(&VerifyOtpRequest { - phone, - token, - verification_type, - }) - .send() - .await + ) -> Result { + let resp = self + .auth_post( + "verify", + &VerifyOtpRequest { + phone, + token, + verification_type, + }, + ) + .await?; + Self::parse_auth_response(resp).await } /// Resends a one-time password to the given phone number. @@ -205,19 +250,17 @@ impl Supabase { &self, phone: &str, verification_type: &str, - ) -> Result { - let url = format!("{}/auth/v1/resend", self.url); - - self.client - .post(&url) - .header("apikey", &self.api_key) - .header("Content-Type", "application/json") - .json(&ResendOtpRequest { - phone, - verification_type, - }) - .send() - .await + ) -> Result { + let resp = self + .auth_post( + "resend", + &ResendOtpRequest { + phone, + verification_type, + }, + ) + .await?; + Self::parse_empty_response(resp).await } /// Signs up a new user with email and password. @@ -225,16 +268,11 @@ impl Supabase { &self, email: &str, password: &str, - ) -> Result { - let url = format!("{}/auth/v1/signup", self.url); - - self.client - .post(&url) - .header("apikey", &self.api_key) - .header("Content-Type", "application/json") - .json(&Credentials { email, password }) - .send() - .await + ) -> Result { + let resp = self + .auth_post("signup", &Credentials { email, password }) + .await?; + Self::parse_auth_response(resp).await } } @@ -243,10 +281,17 @@ mod tests { use super::*; fn client() -> Supabase { - Supabase::new(None, None, None) + Supabase::new(None, None, None).unwrap_or_else(|_| { + Supabase::new( + Some("https://example.supabase.co"), + Some("test-key"), + None, + ) + .unwrap() + }) } - async fn sign_in_password() -> Result { + async fn sign_in_password() -> Result { 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(); @@ -255,99 +300,65 @@ mod tests { #[tokio::test] async fn test_token_with_password() { - let response = match sign_in_password().await { - Ok(resp) => resp, + let auth = match sign_in_password().await { + Ok(auth) => auth, Err(e) => { - println!("Test skipped due to network error: {e}"); + println!("Test skipped due to error: {e}"); return; } }; - let json: serde_json::Value = response.json().await.unwrap(); - - 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.is_empty()); - assert!(!refresh.is_empty()); + assert!(!auth.access_token.is_empty()); + assert!(!auth.refresh_token.is_empty()); } #[tokio::test] async fn test_refresh() { - let response = match sign_in_password().await { - Ok(resp) => resp, + let auth = match sign_in_password().await { + Ok(auth) => auth, Err(e) => { - println!("Test skipped due to network error: {e}"); + println!("Test skipped due to error: {e}"); 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().refresh_token(refresh_token).await { - Ok(resp) => resp, + let refreshed = match client().refresh_token(&auth.refresh_token).await { + Ok(auth) => auth, + Err(crate::Error::Api { status: 400, .. }) => { + println!("Skipping: automatic reuse detection is enabled"); + return; + } Err(e) => { - println!("Test skipped due to network error: {e}"); + println!("Test skipped due to error: {e}"); return; } }; - if response.status() == 400 { - println!("Skipping: automatic reuse detection is enabled"); - 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()); + assert!(!refreshed.access_token.is_empty()); } #[tokio::test] async fn test_logout() { - let response = match sign_in_password().await { - Ok(resp) => resp, + let auth = match sign_in_password().await { + Ok(auth) => auth, Err(e) => { - println!("Test skipped due to network error: {e}"); + println!("Test skipped due to error: {e}"); 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 = client(); - client.set_bearer_token(access_token); + client.set_bearer_token(&auth.access_token); - let response = match client.logout().await { - Ok(Ok(resp)) => resp, - Ok(Err(e)) => { - println!("Test skipped due to network error: {e}"); - return; - } + let resp = match client.logout().await { + Ok(resp) => resp, Err(e) => { println!("Test skipped: {e}"); return; } }; - assert_eq!(response.status(), 204); + assert_eq!(resp.status, 204); } #[tokio::test] @@ -365,125 +376,111 @@ mod tests { let email = format!("{rand_string}@a-rust-domain-that-does-not-exist.com"); - let response = match client.signup_email_password(&email, &rand_string).await { - Ok(resp) => resp, + match client.signup_email_password(&email, &rand_string).await { + Ok(auth) => { + assert!(!auth.access_token.is_empty()); + } Err(e) => { - println!("Test skipped due to network error: {e}"); - return; + println!("Test skipped due to error: {e}"); } - }; - - assert_eq!(response.status(), 200); + } } #[tokio::test] async fn test_authenticate_token() { let client = client(); - let response = match sign_in_password().await { - Ok(resp) => resp, + let auth = match sign_in_password().await { + Ok(auth) => auth, Err(e) => { - println!("Test skipped due to network error: {e}"); + println!("Test skipped due to error: {e}"); 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; - }; - - assert!(client.jwt_valid(token).is_ok()); + assert!(client.jwt_valid(&auth.access_token).is_ok()); } #[test] fn test_logout_requires_bearer_token() { - // Verify the error type displays correctly - assert_eq!(format!("{}", LogoutError), "bearer token required for logout"); + let err = crate::Error::AuthRequired("bearer token required for logout".into()); + assert!(format!("{err}").contains("bearer token required for logout")); } #[tokio::test] async fn test_recover_password() { let client = client(); - let response = match client + match client .recover_password("test@a-rust-domain-that-does-not-exist.com") .await { - Ok(resp) => resp, + Ok(resp) => { + assert!(resp.status >= 200); + } Err(e) => { - println!("Test skipped due to network error: {e}"); - return; + println!("Test skipped due to error: {e}"); } - }; - - let _status = response.status(); + } } #[tokio::test] async fn test_signup_phone_password() { let client = client(); - let response = match client.signup_phone_password("+10000000000", "test-password-123").await + match client + .signup_phone_password("+10000000000", "test-password-123") + .await { - Ok(resp) => resp, + Ok(_auth) => {} + Err(crate::Error::Api { status, .. }) => { + assert!( + status == 422 || status == 401 || status == 403, + "unexpected API error status: {status}" + ); + } Err(e) => { - println!("Test skipped due to network error: {e}"); - return; + println!("Test skipped due to error: {e}"); } - }; - - let status = response.status().as_u16(); - assert!( - status == 200 || status == 422 || status == 401 || status == 403, - "unexpected status: {status}" - ); + } } #[tokio::test] async fn test_sign_in_otp() { let client = client(); - let response = match client.sign_in_otp("+10000000000", Some("sms")).await { - Ok(resp) => resp, + match client.sign_in_otp("+10000000000", Some("sms")).await { + Ok(_resp) => {} + Err(crate::Error::Api { .. }) => {} Err(e) => { - println!("Test skipped due to network error: {e}"); - return; + println!("Test skipped due to error: {e}"); } - }; - - // OTP endpoint should return a response (success or error depending on config) - let _status = response.status(); + } } #[tokio::test] async fn test_verify_otp() { let client = client(); - let response = match client.verify_otp("+10000000000", "000000", "sms").await { - Ok(resp) => resp, + match client.verify_otp("+10000000000", "000000", "sms").await { + Ok(_auth) => {} + Err(crate::Error::Api { .. }) => {} Err(e) => { - println!("Test skipped due to network error: {e}"); - return; + println!("Test skipped due to error: {e}"); } - }; - - let _status = response.status(); + } } #[tokio::test] async fn test_resend_otp() { let client = client(); - let response = match client.resend_otp("+10000000000", "sms").await { - Ok(resp) => resp, + match client.resend_otp("+10000000000", "sms").await { + Ok(_resp) => {} + Err(crate::Error::Api { .. }) => {} Err(e) => { - println!("Test skipped due to network error: {e}"); - return; + println!("Test skipped due to error: {e}"); } - }; - - let _status = response.status(); + } } } diff --git a/src/client.rs b/src/client.rs index c7ef4a2..adc499b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -7,22 +7,49 @@ use crate::Supabase; impl Supabase { /// 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 { - Self { + /// `url` and `api_key` are required — they must be provided as arguments or + /// set via the `SUPABASE_URL` and `SUPABASE_API_KEY` environment variables. + /// Returns `Error::Config` if either value is missing or empty. + /// + /// `jwt` is optional and defaults to an empty string when not provided. + pub fn new( + url: Option<&str>, + api_key: Option<&str>, + jwt: Option<&str>, + ) -> Result { + let url = url + .map(Into::into) + .or_else(|| env::var("SUPABASE_URL").ok()) + .filter(|s: &String| !s.is_empty()) + .ok_or_else(|| { + crate::Error::Config( + "missing SUPABASE_URL: provide as argument or set the environment variable" + .into(), + ) + })?; + + let api_key = api_key + .map(Into::into) + .or_else(|| env::var("SUPABASE_API_KEY").ok()) + .filter(|s: &String| !s.is_empty()) + .ok_or_else(|| { + crate::Error::Config( + "missing SUPABASE_API_KEY: provide as argument or set the environment variable" + .into(), + ) + })?; + + let jwt = jwt + .map(Into::into) + .unwrap_or_else(|| env::var("SUPABASE_JWT_SECRET").unwrap_or_default()); + + Ok(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()), + url, + api_key, + jwt, bearer_token: None, - } + }) } /// Sets the bearer token for authenticated requests. @@ -41,22 +68,64 @@ mod tests { Some("https://example.supabase.co"), Some("test-key"), Some("test-jwt"), - ); + ) + .unwrap(); 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()); + fn test_client_missing_url() { + // Temporarily remove the env var so the constructor can't fall back to it + let saved = env::var("SUPABASE_URL").ok(); + env::remove_var("SUPABASE_URL"); + + let result = Supabase::new(None, Some("key"), None); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, crate::Error::Config(_))); + + if let Some(val) = saved { + env::set_var("SUPABASE_URL", val); + } + } + + #[test] + fn test_client_missing_api_key() { + let saved = env::var("SUPABASE_API_KEY").ok(); + env::remove_var("SUPABASE_API_KEY"); + + let result = Supabase::new(Some("https://example.supabase.co"), None, None); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, crate::Error::Config(_))); + + if let Some(val) = saved { + env::set_var("SUPABASE_API_KEY", val); + } + } + + #[test] + fn test_client_empty_url() { + let result = Supabase::new(Some(""), Some("key"), None); + assert!(result.is_err()); + } + + #[test] + fn test_client_empty_api_key() { + let result = Supabase::new(Some("https://example.supabase.co"), Some(""), None); + assert!(result.is_err()); } #[test] fn test_set_bearer_token() { - let mut client = Supabase::new(None, None, None); + let mut client = Supabase::new( + Some("https://example.supabase.co"), + Some("test-key"), + None, + ) + .unwrap(); 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 a9b50fc..3407378 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,50 +1,15 @@ -use reqwest::{Error, Method, Response}; -use serde::Serialize; +use reqwest::{Method, Response}; +use serde::{de::DeserializeOwned, 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. +/// +/// Use [`execute()`](Self::execute) to get the raw response, or +/// [`execute_and_parse()`](Self::execute_and_parse) to deserialize the JSON body. +#[must_use = "a QueryBuilder does nothing until .execute() or .execute_and_parse() is called"] pub struct QueryBuilder<'a> { client: &'a Supabase, table: String, @@ -77,7 +42,7 @@ impl<'a> QueryBuilder<'a> { /// Prepares an insert operation with the provided data. /// /// Data will be serialized to JSON. Call `execute()` to run the query. - pub fn insert(mut self, data: &T) -> Result { + pub fn insert(mut self, data: &T) -> Result { self.method = Method::POST; self.body = Some(serde_json::to_string(data)?); Ok(self) @@ -86,7 +51,7 @@ impl<'a> QueryBuilder<'a> { /// 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) -> Result { + pub fn update(mut self, data: &T) -> Result { self.method = Method::PATCH; self.body = Some(serde_json::to_string(data)?); Ok(self) @@ -188,8 +153,10 @@ impl<'a> QueryBuilder<'a> { self } - /// Executes the query and returns the response. - pub async fn execute(self) -> Result { + /// Executes the query and returns the raw response. + /// + /// Returns `Error::Api` if the server responds with a non-2xx status code. + pub async fn execute(self) -> Result { let url = format!("{}/rest/v1/{}", self.client.url, self.table); let mut request = self @@ -211,7 +178,26 @@ impl<'a> QueryBuilder<'a> { request = request.body(body); } - request.send().await + let resp = request.send().await?; + + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + let message = resp.text().await.unwrap_or_default(); + return Err(crate::Error::Api { status, message }); + } + + Ok(resp) + } + + /// Executes the query and deserializes the JSON response body into `T`. + /// + /// This is a convenience wrapper around [`execute()`](Self::execute) that + /// also parses the response body. + pub async fn execute_and_parse(self) -> Result { + let resp = self.execute().await?; + let body = resp.text().await?; + let parsed: T = serde_json::from_str(&body)?; + Ok(parsed) } fn add_filter( @@ -260,7 +246,23 @@ mod tests { use serde::{Deserialize, Serialize}; fn client() -> Supabase { - Supabase::new(None, None, None) + Supabase::new(None, None, None).unwrap_or_else(|_| { + Supabase::new( + Some("https://example.supabase.co"), + Some("test-key"), + None, + ) + .unwrap() + }) + } + + /// Helper: returns true if the error is acceptable for tests running without + /// a real Supabase backend (network errors or 401 API errors). + fn is_acceptable_error(err: &crate::Error) -> bool { + matches!( + err, + crate::Error::Request(_) | crate::Error::Api { status: 401, .. } + ) } #[derive(Debug, Serialize, Deserialize)] @@ -273,16 +275,12 @@ mod tests { async fn test_select() { let client = client(); - let result = client.from("test_items").select("*").execute().await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + match client.from("test_items").select("*").execute().await { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected error: {e}"), } } @@ -290,16 +288,12 @@ mod tests { async fn test_select_columns() { let client = client(); - let result = client.from("test_items").select("id,name").execute().await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + match client.from("test_items").select("id,name").execute().await { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected error: {e}"), } } @@ -307,21 +301,18 @@ mod tests { async fn test_select_with_filter() { let client = client(); - let result = client + match client .from("test_items") .select("*") .eq("name", "test") .execute() - .await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + .await + { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected error: {e}"), } } @@ -334,21 +325,18 @@ mod tests { value: 42, }; - let result = client + match client .from("test_items") .insert(&item) .expect("serialization should succeed") .execute() - .await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + .await + { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected error: {e}"), } } @@ -358,22 +346,19 @@ mod tests { let updates = serde_json::json!({ "value": 100 }); - let result = client + match client .from("test_items") .update(&updates) .expect("serialization should succeed") .eq("name", "test_item") .execute() - .await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + .await + { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected error: {e}"), } } @@ -381,21 +366,18 @@ mod tests { async fn test_delete() { let client = client(); - let result = client + match client .from("test_items") .delete() .eq("name", "test_item") .execute() - .await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + .await + { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected error: {e}"), } } @@ -403,22 +385,19 @@ mod tests { async fn test_select_with_order_and_limit() { let client = client(); - let result = client + match client .from("test_items") .select("*") .order("id.desc") .limit(10) .execute() - .await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + .await + { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected error: {e}"), } } @@ -426,22 +405,19 @@ mod tests { async fn test_select_with_multiple_filters() { let client = client(); - let result = client + match client .from("test_items") .select("*") .gte("value", "10") .lte("value", "100") .execute() - .await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + .await + { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected error: {e}"), } } @@ -449,29 +425,27 @@ mod tests { async fn test_in_filter() { let client = client(); - let result = client + match client .from("test_items") .select("*") .in_("id", ["1", "2", "3"]) .execute() - .await; - - match result { - Ok(resp) => { - let status = resp.status(); - assert!(status.is_success() || status.as_u16() == 401); - } - Err(e) => { - println!("Test skipped due to network error: {e}"); + .await + { + Ok(_resp) => {} + Err(e) if is_acceptable_error(&e) => { + println!("Test skipped: {e}"); } + Err(e) => panic!("unexpected 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")); + fn test_error_display() { + let err = crate::Error::Api { + status: 400, + message: "bad request".into(), + }; + assert!(format!("{err}").contains("400")); } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..989f3ab --- /dev/null +++ b/src/error.rs @@ -0,0 +1,32 @@ +/// Unified error type for all supabase-rust operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Missing or empty configuration value (URL or API key). + #[error("configuration error: {0}")] + Config(String), + + /// HTTP transport failure. + #[error(transparent)] + Request(#[from] reqwest::Error), + + /// JSON serialization or deserialization failure. + #[error(transparent)] + Serialization(#[from] serde_json::Error), + + /// JWT validation failure. + #[error(transparent)] + Jwt(#[from] jsonwebtoken::errors::Error), + + /// An authenticated operation was attempted without a bearer token. + #[error("authentication required: {0}")] + AuthRequired(String), + + /// The Supabase API returned a non-2xx response. + #[error("API error {status}: {message}")] + Api { + /// HTTP status code. + status: u16, + /// Error message from the response body. + message: String, + }, +} diff --git a/src/lib.rs b/src/lib.rs index d0e192a..e7e69b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,50 @@ +//! # supabase-rust +//! +//! An unofficial Rust client for [Supabase](https://supabase.com). +//! +//! Provides typed access to Supabase Auth and PostgREST (database) APIs. +//! +//! ## Quick start +//! +//! ```rust,no_run +//! use supabase_rust::{Supabase, AuthResponse, Error}; +//! +//! # async fn run() -> Result<(), Error> { +//! let client = Supabase::new( +//! Some("https://your-project.supabase.co"), +//! Some("your-api-key"), +//! None, +//! )?; +//! +//! // Sign in +//! let auth: AuthResponse = client +//! .sign_in_password("user@example.com", "password") +//! .await?; +//! println!("access token: {}", auth.access_token); +//! +//! // Query the database +//! let response = client +//! .from("todos") +//! .select("*") +//! .eq("user_id", &auth.access_token) +//! .execute() +//! .await?; +//! # Ok(()) +//! # } +//! ``` + use reqwest::Client; pub mod auth; -mod client; pub mod db; +pub mod error; +mod client; + +pub use auth::{AuthResponse, Claims}; +pub use db::QueryBuilder; +pub use error::Error; +/// The Supabase client. Entry point for all operations. #[derive(Clone, Debug)] pub struct Supabase { client: Client,