#![cfg_attr(doc, doc = include_str!("../README.md"))]
#[cfg(feature = "rest")]
pub use rp_postgrest as postgrest;
#[cfg(feature = "auth")]
pub use rp_supabase_auth as auth;
#[cfg(feature = "typed")]
pub use rp_supabase_client as typed;
#[cfg(feature = "functions")]
pub use rp_supabase_functions as functions;
#[cfg(feature = "realtime")]
pub use rp_supabase_realtime as realtime;
#[cfg(feature = "storage")]
pub use rp_supabase_storage as storage;
pub use url::Url;
#[cfg(feature = "rest")]
const API_KEY_HEADER: &str = "apikey";
#[cfg(feature = "realtime")]
pub const REALTIME_MAX_RECONNECT_ATTEMPTS: u8 = 5;
#[cfg(feature = "realtime")]
pub const REALTIME_RECONNECT_INTERVAL: core::time::Duration = core::time::Duration::from_secs(3);
#[derive(Debug, thiserror::Error)]
#[expect(
clippy::error_impl_error,
reason = "the crate exposes one error type named `Error`"
)]
#[non_exhaustive]
pub enum Error {
#[error("invalid project URL: {0}")]
UrlParse(#[from] url::ParseError),
#[error("invalid project URL: {0}")]
InvalidProjectUrl(&'static str),
#[cfg(feature = "rest")]
#[error(transparent)]
Rest(#[from] rp_postgrest::Error),
#[cfg(feature = "auth")]
#[error(transparent)]
Auth(#[from] rp_supabase_auth::error::AuthError),
#[cfg(feature = "storage")]
#[error(transparent)]
Storage(#[from] rp_supabase_storage::StorageError),
#[cfg(feature = "functions")]
#[error(transparent)]
Functions(#[from] rp_supabase_functions::FunctionsError),
#[error(transparent)]
Http(#[from] reqwest::Error),
}
#[derive(Clone)]
pub struct Client {
project_url: Url,
api_key: String,
http: reqwest::Client,
#[cfg(feature = "rest")]
rest: rp_postgrest::Postgrest,
#[cfg(feature = "auth")]
auth: rp_supabase_auth::auth_client::ApiClient,
#[cfg(feature = "storage")]
storage: rp_supabase_storage::StorageClient,
#[cfg(feature = "functions")]
functions: rp_supabase_functions::FunctionsClient,
}
impl core::fmt::Debug for Client {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Client")
.field("project_url", &self.project_url.as_str())
.field("api_key", &"[redacted]")
.finish_non_exhaustive()
}
}
fn same_origin_redirects() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let same_origin = attempt
.previous()
.last()
.is_some_and(|prev| prev.origin() == attempt.url().origin());
if !same_origin {
attempt.stop()
} else if attempt.previous().len() > 10 {
attempt.error("too many redirects")
} else {
attempt.follow()
}
})
}
impl Client {
pub fn new(project_url: &str, api_key: &str) -> Result<Self, Error> {
let http = reqwest::Client::builder()
.redirect(same_origin_redirects())
.build()?;
Self::new_with_client(project_url, api_key, http)
}
pub fn new_with_client(
project_url: &str,
api_key: &str,
http: reqwest::Client,
) -> Result<Self, Error> {
let project_url = parse_project_url(project_url)?;
Ok(Self {
#[cfg(feature = "rest")]
rest: rest_client(&project_url, api_key, default_bearer(api_key), http.clone())?,
#[cfg(feature = "auth")]
auth: auth_client(&project_url, api_key, default_bearer(api_key), http.clone())?,
#[cfg(feature = "storage")]
storage: rp_supabase_storage::StorageClient::new_with_client(
&project_url,
api_key,
http.clone(),
)?,
#[cfg(feature = "functions")]
functions: rp_supabase_functions::FunctionsClient::new_with_client(
&project_url,
api_key,
http.clone(),
)?,
api_key: api_key.to_owned(),
http,
project_url,
})
}
#[cfg_attr(
not(any(
feature = "rest",
feature = "auth",
feature = "storage",
feature = "functions"
)),
expect(
unused_variables,
clippy::unnecessary_wraps,
reason = "no enabled service uses a token"
)
)]
pub fn with_access_token(&self, token: &str) -> Result<Self, Error> {
Ok(Self {
project_url: self.project_url.clone(),
api_key: self.api_key.clone(),
http: self.http.clone(),
#[cfg(feature = "rest")]
rest: rest_client(
&self.project_url,
&self.api_key,
Some(token),
self.http.clone(),
)?,
#[cfg(feature = "auth")]
auth: auth_client(
&self.project_url,
&self.api_key,
Some(token),
self.http.clone(),
)?,
#[cfg(feature = "storage")]
storage: self.storage.with_access_token(token)?,
#[cfg(feature = "functions")]
functions: self.functions.with_access_token(token)?,
})
}
#[cfg(feature = "storage")]
pub fn with_storage_url(&self, project_url: &str) -> Result<Self, Error> {
let storage_url = parse_project_url(project_url)?;
let mut next = self.clone();
next.storage = self.storage.with_project_url(&storage_url)?;
Ok(next)
}
#[must_use]
pub const fn project_url(&self) -> &Url {
&self.project_url
}
#[cfg(feature = "rest")]
#[must_use]
pub const fn rest(&self) -> &rp_postgrest::Postgrest {
&self.rest
}
#[cfg(feature = "rest")]
pub fn from(&self, table: &str) -> rp_postgrest::Builder {
self.rest.from(table)
}
#[cfg(feature = "auth")]
#[must_use]
pub const fn auth(&self) -> &rp_supabase_auth::auth_client::ApiClient {
&self.auth
}
#[cfg(feature = "storage")]
#[must_use]
pub const fn storage(&self) -> &rp_supabase_storage::StorageClient {
&self.storage
}
#[cfg(feature = "functions")]
#[must_use]
pub const fn functions(&self) -> &rp_supabase_functions::FunctionsClient {
&self.functions
}
#[cfg(feature = "realtime")]
#[must_use]
pub fn realtime_config(
&self,
) -> rp_supabase_realtime::rp_supabase_auth::jwt_stream::SupabaseAuthConfig {
rp_supabase_realtime::rp_supabase_auth::jwt_stream::SupabaseAuthConfig {
api_key: self.api_key.clone(),
max_reconnect_attempts: REALTIME_MAX_RECONNECT_ATTEMPTS,
reconnect_interval: REALTIME_RECONNECT_INTERVAL,
url: self.project_url.clone(),
}
}
}
fn parse_project_url(input: &str) -> Result<Url, Error> {
let url = Url::parse(input)?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::InvalidProjectUrl("scheme must be http or https"));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(Error::InvalidProjectUrl("credentials are not allowed"));
}
if url.query().is_some() {
return Err(Error::InvalidProjectUrl("query is not allowed"));
}
if url.fragment().is_some() {
return Err(Error::InvalidProjectUrl("fragment is not allowed"));
}
if url.path() != "/" {
return Err(Error::InvalidProjectUrl(
"path is not allowed; pass the project root URL",
));
}
Ok(url)
}
#[cfg(any(feature = "rest", feature = "auth"))]
fn default_bearer(api_key: &str) -> Option<&str> {
let new_format = api_key.starts_with("sb_publishable_") || api_key.starts_with("sb_secret_");
(!new_format).then_some(api_key)
}
#[cfg(feature = "auth")]
fn auth_client(
project_url: &Url,
api_key: &str,
bearer: Option<&str>,
http: reqwest::Client,
) -> Result<rp_supabase_auth::auth_client::ApiClient, Error> {
use rp_supabase_auth::auth_client::ApiClient;
Ok(match bearer {
Some(bearer) => {
ApiClient::new_authenticated_with_client(project_url, api_key, bearer, http)?
}
None => ApiClient::new_unauthenticated_with_client(project_url, api_key, http)?,
})
}
#[cfg(feature = "rest")]
fn rest_client(
project_url: &Url,
api_key: &str,
bearer: Option<&str>,
http: reqwest::Client,
) -> Result<rp_postgrest::Postgrest, Error> {
let base = project_url.join("rest/v1/")?;
let client = rp_postgrest::Postgrest::new_with_client(base.as_str(), http)?
.insert_header(API_KEY_HEADER, api_key)?;
Ok(match bearer {
Some(bearer) => client.auth(bearer)?,
None => client,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_bad_project_urls() {
for input in [
"not a url",
"ftp://abc.supabase.co/",
"https://user:pass@abc.supabase.co/",
"https://abc.supabase.co/?x=1",
"https://abc.supabase.co/#frag",
"https://gateway.example/supabase",
] {
assert!(
Client::new(input, "key").is_err(),
"{input} must be rejected"
);
}
}
}