Skip to main content

supabase_rp/
lib.rs

1#![cfg_attr(doc, doc = include_str!("../README.md"))]
2
3/// `PostgREST` query builder (`rp-postgrest`).
4#[cfg(feature = "rest")]
5pub use rp_postgrest as postgrest;
6/// Supabase Auth API client and token streams (`rp-supabase-auth`).
7#[cfg(feature = "auth")]
8pub use rp_supabase_auth as auth;
9/// Typed runtime for generated schemas (`rp-supabase-client`).
10///
11/// The `typed` feature enables only the schema runtime (`typed::schema`). Generate code with
12/// the codegen `runtime_path` set to `::supabase_rp::typed::schema`. The `typed-client`
13/// feature also enables the client API of `rp-supabase-client` (authentication and response
14/// decoding), which turns on `serde_json/arbitrary_precision`.
15#[cfg(feature = "typed")]
16pub use rp_supabase_client as typed;
17/// Edge Functions client (`rp-supabase-functions`).
18#[cfg(feature = "functions")]
19pub use rp_supabase_functions as functions;
20/// Realtime changes, broadcast, and presence (`rp-supabase-realtime`).
21#[cfg(feature = "realtime")]
22pub use rp_supabase_realtime as realtime;
23/// Storage buckets and objects (`rp-supabase-storage`).
24#[cfg(feature = "storage")]
25pub use rp_supabase_storage as storage;
26pub use url::Url;
27
28/// Header that carries the project API key.
29#[cfg(feature = "rest")]
30const API_KEY_HEADER: &str = "apikey";
31
32/// Maximum sign-in and token refresh retries in [`Client::realtime_config`].
33#[cfg(feature = "realtime")]
34pub const REALTIME_MAX_RECONNECT_ATTEMPTS: u8 = 5;
35
36/// Delay between sign-in and token refresh retries in [`Client::realtime_config`].
37#[cfg(feature = "realtime")]
38pub const REALTIME_RECONNECT_INTERVAL: core::time::Duration = core::time::Duration::from_secs(3);
39
40/// Errors from building a [`Client`].
41#[derive(Debug, thiserror::Error)]
42#[expect(
43    clippy::error_impl_error,
44    reason = "the crate exposes one error type named `Error`"
45)]
46#[non_exhaustive]
47pub enum Error {
48    /// The project URL does not parse.
49    #[error("invalid project URL: {0}")]
50    UrlParse(#[from] url::ParseError),
51    /// The project URL parses but is not a usable project base URL.
52    #[error("invalid project URL: {0}")]
53    InvalidProjectUrl(&'static str),
54    /// The REST client rejected its configuration.
55    #[cfg(feature = "rest")]
56    #[error(transparent)]
57    Rest(#[from] rp_postgrest::Error),
58    /// The auth client rejected its configuration.
59    #[cfg(feature = "auth")]
60    #[error(transparent)]
61    Auth(#[from] rp_supabase_auth::error::AuthError),
62    /// The storage client rejected its configuration.
63    #[cfg(feature = "storage")]
64    #[error(transparent)]
65    Storage(#[from] rp_supabase_storage::StorageError),
66    /// The functions client rejected its configuration.
67    #[cfg(feature = "functions")]
68    #[error(transparent)]
69    Functions(#[from] rp_supabase_functions::FunctionsError),
70    /// The default HTTP client cannot be built.
71    #[error(transparent)]
72    Http(#[from] reqwest::Error),
73}
74
75/// Client for one Supabase project.
76///
77/// It holds one shared `reqwest::Client` and one sub-client per enabled feature.
78/// Cloning is cheap: the connection pool is shared.
79#[derive(Clone)]
80pub struct Client {
81    project_url: Url,
82    api_key: String,
83    http: reqwest::Client,
84    #[cfg(feature = "rest")]
85    rest: rp_postgrest::Postgrest,
86    #[cfg(feature = "auth")]
87    auth: rp_supabase_auth::auth_client::ApiClient,
88    #[cfg(feature = "storage")]
89    storage: rp_supabase_storage::StorageClient,
90    #[cfg(feature = "functions")]
91    functions: rp_supabase_functions::FunctionsClient,
92}
93
94impl core::fmt::Debug for Client {
95    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
96        f.debug_struct("Client")
97            .field("project_url", &self.project_url.as_str())
98            .field("api_key", &"[redacted]")
99            .finish_non_exhaustive()
100    }
101}
102
103/// Follows up to 10 redirects, but only to the same origin (scheme, host, port), so
104/// credential headers never reach another host.
105fn same_origin_redirects() -> reqwest::redirect::Policy {
106    reqwest::redirect::Policy::custom(|attempt| {
107        let same_origin = attempt
108            .previous()
109            .last()
110            .is_some_and(|prev| prev.origin() == attempt.url().origin());
111        if !same_origin {
112            attempt.stop()
113        } else if attempt.previous().len() > 10 {
114            attempt.error("too many redirects")
115        } else {
116            attempt.follow()
117        }
118    })
119}
120
121impl Client {
122    /// Creates a client for the project at `project_url`, for example `https://abc.supabase.co/`.
123    ///
124    /// The default HTTP client follows up to 10 redirects, only to the same origin.
125    ///
126    /// # Errors
127    /// Returns [`Error::UrlParse`] or [`Error::InvalidProjectUrl`] for a bad URL, and a
128    /// sub-client error when a credential is not a valid header value.
129    pub fn new(project_url: &str, api_key: &str) -> Result<Self, Error> {
130        let http = reqwest::Client::builder()
131            .redirect(same_origin_redirects())
132            .build()?;
133        Self::new_with_client(project_url, api_key, http)
134    }
135
136    /// Creates a client that reuses the connection pool and policies of `http`.
137    /// The redirect policy of `http` applies unchanged.
138    ///
139    /// # Errors
140    /// Same as [`Client::new`].
141    pub fn new_with_client(
142        project_url: &str,
143        api_key: &str,
144        http: reqwest::Client,
145    ) -> Result<Self, Error> {
146        let project_url = parse_project_url(project_url)?;
147        Ok(Self {
148            #[cfg(feature = "rest")]
149            rest: rest_client(&project_url, api_key, default_bearer(api_key), http.clone())?,
150            #[cfg(feature = "auth")]
151            auth: auth_client(&project_url, api_key, default_bearer(api_key), http.clone())?,
152            #[cfg(feature = "storage")]
153            storage: rp_supabase_storage::StorageClient::new_with_client(
154                &project_url,
155                api_key,
156                http.clone(),
157            )?,
158            #[cfg(feature = "functions")]
159            functions: rp_supabase_functions::FunctionsClient::new_with_client(
160                &project_url,
161                api_key,
162                http.clone(),
163            )?,
164            api_key: api_key.to_owned(),
165            http,
166            project_url,
167        })
168    }
169
170    /// Returns a client that acts as the user who owns `token` (a JWT from sign-in).
171    ///
172    /// REST, auth, storage, and functions send `Authorization: Bearer <token>`.
173    /// The `apikey` header keeps the project key. The connection pool is shared.
174    /// Without a user token, a legacy JWT key (anon or `service_role`) is the bearer, as in
175    /// supabase-js; a new-format key (`sb_publishable_...`, `sb_secret_...`) is sent only as
176    /// `apikey`.
177    ///
178    /// # Errors
179    /// Returns a sub-client error when `token` is not a valid header value.
180    #[cfg_attr(
181        not(any(
182            feature = "rest",
183            feature = "auth",
184            feature = "storage",
185            feature = "functions"
186        )),
187        expect(
188            unused_variables,
189            clippy::unnecessary_wraps,
190            reason = "no enabled service uses a token"
191        )
192    )]
193    pub fn with_access_token(&self, token: &str) -> Result<Self, Error> {
194        Ok(Self {
195            project_url: self.project_url.clone(),
196            api_key: self.api_key.clone(),
197            http: self.http.clone(),
198            #[cfg(feature = "rest")]
199            rest: rest_client(
200                &self.project_url,
201                &self.api_key,
202                Some(token),
203                self.http.clone(),
204            )?,
205            #[cfg(feature = "auth")]
206            auth: auth_client(
207                &self.project_url,
208                &self.api_key,
209                Some(token),
210                self.http.clone(),
211            )?,
212            #[cfg(feature = "storage")]
213            storage: self.storage.with_access_token(token)?,
214            #[cfg(feature = "functions")]
215            functions: self.functions.with_access_token(token)?,
216        })
217    }
218
219    /// Returns a client that sends storage requests to the project at `project_url`, for
220    /// example `https://abc.supabase.co/`. REST, auth, and functions keep the URL from
221    /// [`Client::new`].
222    ///
223    /// Use it when a custom domain proxies REST and auth but not storage.
224    /// The storage client keeps its headers, so an access token from
225    /// [`Client::with_access_token`] stays. Call order does not matter.
226    ///
227    /// # Errors
228    /// Returns [`Error::UrlParse`] or [`Error::InvalidProjectUrl`] for a bad URL.
229    #[cfg(feature = "storage")]
230    pub fn with_storage_url(&self, project_url: &str) -> Result<Self, Error> {
231        let storage_url = parse_project_url(project_url)?;
232        let mut next = self.clone();
233        next.storage = self.storage.with_project_url(&storage_url)?;
234        Ok(next)
235    }
236
237    /// Project base URL.
238    #[must_use]
239    pub const fn project_url(&self) -> &Url {
240        &self.project_url
241    }
242
243    /// REST client for `{project}/rest/v1/`.
244    ///
245    /// The typed runtime of `rp-supabase-client` takes this same `Postgrest`.
246    #[cfg(feature = "rest")]
247    #[must_use]
248    pub const fn rest(&self) -> &rp_postgrest::Postgrest {
249        &self.rest
250    }
251
252    /// Starts a query on `table`. Shortcut for `self.rest().from(table)`.
253    #[cfg(feature = "rest")]
254    pub fn from(&self, table: &str) -> rp_postgrest::Builder {
255        self.rest.from(table)
256    }
257
258    /// Supabase Auth client for `{project}/auth/v1/`.
259    ///
260    /// After [`Client::with_access_token`] it is authenticated, so `get_user`,
261    /// `update_user`, and `sign_out` work.
262    #[cfg(feature = "auth")]
263    #[must_use]
264    pub const fn auth(&self) -> &rp_supabase_auth::auth_client::ApiClient {
265        &self.auth
266    }
267
268    /// Storage client for `{project}/storage/v1/`.
269    #[cfg(feature = "storage")]
270    #[must_use]
271    pub const fn storage(&self) -> &rp_supabase_storage::StorageClient {
272        &self.storage
273    }
274
275    /// Edge Functions client for `{project}/functions/v1/`.
276    #[cfg(feature = "functions")]
277    #[must_use]
278    pub const fn functions(&self) -> &rp_supabase_functions::FunctionsClient {
279        &self.functions
280    }
281
282    /// Configuration for `RealtimeConnection::db_changes`, `presence`, and `broadcast`.
283    ///
284    /// Realtime signs in on its own, so it uses the project key, not a user token.
285    /// Retry defaults for sign-in and token refresh: [`REALTIME_MAX_RECONNECT_ATTEMPTS`]
286    /// attempts, [`REALTIME_RECONNECT_INTERVAL`] apart. The websocket itself does not reconnect.
287    /// Change the returned fields to override the retry settings.
288    #[cfg(feature = "realtime")]
289    #[must_use]
290    pub fn realtime_config(
291        &self,
292    ) -> rp_supabase_realtime::rp_supabase_auth::jwt_stream::SupabaseAuthConfig {
293        rp_supabase_realtime::rp_supabase_auth::jwt_stream::SupabaseAuthConfig {
294            api_key: self.api_key.clone(),
295            max_reconnect_attempts: REALTIME_MAX_RECONNECT_ATTEMPTS,
296            reconnect_interval: REALTIME_RECONNECT_INTERVAL,
297            url: self.project_url.clone(),
298        }
299    }
300}
301
302fn parse_project_url(input: &str) -> Result<Url, Error> {
303    let url = Url::parse(input)?;
304    if !matches!(url.scheme(), "http" | "https") {
305        return Err(Error::InvalidProjectUrl("scheme must be http or https"));
306    }
307    if !url.username().is_empty() || url.password().is_some() {
308        return Err(Error::InvalidProjectUrl("credentials are not allowed"));
309    }
310    if url.query().is_some() {
311        return Err(Error::InvalidProjectUrl("query is not allowed"));
312    }
313    if url.fragment().is_some() {
314        return Err(Error::InvalidProjectUrl("fragment is not allowed"));
315    }
316    // Auth joins `/auth/v1/` from the host root, so a path prefix would route services apart.
317    if url.path() != "/" {
318        return Err(Error::InvalidProjectUrl(
319            "path is not allowed; pass the project root URL",
320        ));
321    }
322    Ok(url)
323}
324
325/// Returns the API key as the default bearer for legacy JWT keys. New-format keys
326/// (`sb_publishable_`, `sb_secret_`) are not JWTs, so supabase-js sends them only as `apikey`.
327#[cfg(any(feature = "rest", feature = "auth"))]
328fn default_bearer(api_key: &str) -> Option<&str> {
329    let new_format = api_key.starts_with("sb_publishable_") || api_key.starts_with("sb_secret_");
330    (!new_format).then_some(api_key)
331}
332
333/// Builds the auth client, authenticated when a bearer is given.
334#[cfg(feature = "auth")]
335fn auth_client(
336    project_url: &Url,
337    api_key: &str,
338    bearer: Option<&str>,
339    http: reqwest::Client,
340) -> Result<rp_supabase_auth::auth_client::ApiClient, Error> {
341    use rp_supabase_auth::auth_client::ApiClient;
342    Ok(match bearer {
343        Some(bearer) => {
344            ApiClient::new_authenticated_with_client(project_url, api_key, bearer, http)?
345        }
346        None => ApiClient::new_unauthenticated_with_client(project_url, api_key, http)?,
347    })
348}
349
350/// Builds the REST client like `rp_supabase_client::anonymous_client_with_client`, plus an
351/// optional bearer.
352#[cfg(feature = "rest")]
353fn rest_client(
354    project_url: &Url,
355    api_key: &str,
356    bearer: Option<&str>,
357    http: reqwest::Client,
358) -> Result<rp_postgrest::Postgrest, Error> {
359    let base = project_url.join("rest/v1/")?;
360    let client = rp_postgrest::Postgrest::new_with_client(base.as_str(), http)?
361        .insert_header(API_KEY_HEADER, api_key)?;
362    Ok(match bearer {
363        Some(bearer) => client.auth(bearer)?,
364        None => client,
365    })
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn rejects_bad_project_urls() {
374        for input in [
375            "not a url",
376            "ftp://abc.supabase.co/",
377            "https://user:pass@abc.supabase.co/",
378            "https://abc.supabase.co/?x=1",
379            "https://abc.supabase.co/#frag",
380            "https://gateway.example/supabase",
381        ] {
382            assert!(
383                Client::new(input, "key").is_err(),
384                "{input} must be rejected"
385            );
386        }
387    }
388}