1#![cfg_attr(doc, doc = include_str!("../README.md"))]
2
3#[cfg(feature = "rest")]
5pub use rp_postgrest as postgrest;
6#[cfg(feature = "auth")]
8pub use rp_supabase_auth as auth;
9#[cfg(feature = "typed")]
16pub use rp_supabase_client as typed;
17#[cfg(feature = "functions")]
19pub use rp_supabase_functions as functions;
20#[cfg(feature = "realtime")]
22pub use rp_supabase_realtime as realtime;
23#[cfg(feature = "storage")]
25pub use rp_supabase_storage as storage;
26pub use url::Url;
27
28#[cfg(feature = "rest")]
30const API_KEY_HEADER: &str = "apikey";
31
32#[cfg(feature = "realtime")]
34pub const REALTIME_MAX_RECONNECT_ATTEMPTS: u8 = 5;
35
36#[cfg(feature = "realtime")]
38pub const REALTIME_RECONNECT_INTERVAL: core::time::Duration = core::time::Duration::from_secs(3);
39
40#[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 #[error("invalid project URL: {0}")]
50 UrlParse(#[from] url::ParseError),
51 #[error("invalid project URL: {0}")]
53 InvalidProjectUrl(&'static str),
54 #[cfg(feature = "rest")]
56 #[error(transparent)]
57 Rest(#[from] rp_postgrest::Error),
58 #[cfg(feature = "auth")]
60 #[error(transparent)]
61 Auth(#[from] rp_supabase_auth::error::AuthError),
62 #[cfg(feature = "storage")]
64 #[error(transparent)]
65 Storage(#[from] rp_supabase_storage::StorageError),
66 #[cfg(feature = "functions")]
68 #[error(transparent)]
69 Functions(#[from] rp_supabase_functions::FunctionsError),
70 #[error(transparent)]
72 Http(#[from] reqwest::Error),
73}
74
75#[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
103fn 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 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 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 #[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 #[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 #[must_use]
239 pub const fn project_url(&self) -> &Url {
240 &self.project_url
241 }
242
243 #[cfg(feature = "rest")]
247 #[must_use]
248 pub const fn rest(&self) -> &rp_postgrest::Postgrest {
249 &self.rest
250 }
251
252 #[cfg(feature = "rest")]
254 pub fn from(&self, table: &str) -> rp_postgrest::Builder {
255 self.rest.from(table)
256 }
257
258 #[cfg(feature = "auth")]
263 #[must_use]
264 pub const fn auth(&self) -> &rp_supabase_auth::auth_client::ApiClient {
265 &self.auth
266 }
267
268 #[cfg(feature = "storage")]
270 #[must_use]
271 pub const fn storage(&self) -> &rp_supabase_storage::StorageClient {
272 &self.storage
273 }
274
275 #[cfg(feature = "functions")]
277 #[must_use]
278 pub const fn functions(&self) -> &rp_supabase_functions::FunctionsClient {
279 &self.functions
280 }
281
282 #[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 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#[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#[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#[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}