Skip to main content

rp_supabase_storage/
client.rs

1use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
2use reqwest::{Method, RequestBuilder, Response};
3use serde::Serialize;
4use serde::de::DeserializeOwned;
5use url::Url;
6
7use crate::error::{ApiErrorBody, StorageError};
8use crate::types::{BucketInfo, BucketOptions};
9use crate::{Bucket, path};
10
11/// Follows up to 10 redirects, but only to the same origin (scheme, host, port), so
12/// credential headers never reach another host.
13fn same_origin_redirects() -> reqwest::redirect::Policy {
14    reqwest::redirect::Policy::custom(|attempt| {
15        let same_origin = attempt
16            .previous()
17            .last()
18            .is_some_and(|prev| prev.origin() == attempt.url().origin());
19        if !same_origin {
20            attempt.stop()
21        } else if attempt.previous().len() > 10 {
22            attempt.error("too many redirects")
23        } else {
24            attempt.follow()
25        }
26    })
27}
28
29/// Client for the Supabase Storage API at `<project>/storage/v1`.
30///
31/// Cloning is cheap; clones share the HTTP connection pool.
32#[derive(Debug, Clone)]
33pub struct StorageClient {
34    http: reqwest::Client,
35    base: Url,
36    headers: HeaderMap,
37}
38
39#[derive(Serialize)]
40struct BucketBody<'a> {
41    id: &'a str,
42    name: &'a str,
43    #[serde(flatten)]
44    options: &'a BucketOptions,
45}
46
47impl StorageClient {
48    /// Creates a client for the project at `project_url` (for example `https://abc.supabase.co/`).
49    ///
50    /// Requests send `apikey: <api_key>`. A legacy JWT key is also sent as
51    /// `Authorization: Bearer <api_key>`. A new-format key (`sb_publishable_...` or
52    /// `sb_secret_...`) is not a JWT, so the client sends no `Authorization` header until
53    /// [`StorageClient::with_access_token`] sets one.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error when the URL cannot be a base, `api_key` is not a valid header value, or
58    /// the HTTP client cannot be built.
59    pub fn new(project_url: &Url, api_key: &str) -> Result<Self, StorageError> {
60        let http = reqwest::Client::builder()
61            .redirect(same_origin_redirects())
62            .build()?;
63        Self::new_with_client(project_url, api_key, http)
64    }
65
66    /// Like [`StorageClient::new`], but reuses the connection pool of `http`.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error when the URL cannot be a base or `api_key` is not a valid header value.
71    pub fn new_with_client(
72        project_url: &Url,
73        api_key: &str,
74        http: reqwest::Client,
75    ) -> Result<Self, StorageError> {
76        let base = storage_base(project_url)?;
77        let mut headers = HeaderMap::new();
78        headers.insert("apikey", sensitive(api_key)?);
79        if !(api_key.starts_with("sb_publishable_") || api_key.starts_with("sb_secret_")) {
80            headers.insert(AUTHORIZATION, sensitive(&format!("Bearer {api_key}"))?);
81        }
82        Ok(Self {
83            http,
84            base,
85            headers,
86        })
87    }
88
89    /// Returns a copy that sends `Authorization: Bearer <token>` (a user JWT) so that row level
90    /// security policies apply to that user. The `apikey` header stays unchanged.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error when `token` is not a valid header value.
95    pub fn with_access_token(&self, token: &str) -> Result<Self, StorageError> {
96        let mut next = self.clone();
97        next.headers
98            .insert(AUTHORIZATION, sensitive(&format!("Bearer {token}"))?);
99        Ok(next)
100    }
101
102    /// Returns a copy that sends requests to the project at `project_url` (joined with
103    /// `storage/v1`). All headers, including any access token, stay unchanged.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error when the URL cannot be a base.
108    pub fn with_project_url(&self, project_url: &Url) -> Result<Self, StorageError> {
109        Ok(Self {
110            http: self.http.clone(),
111            base: storage_base(project_url)?,
112            headers: self.headers.clone(),
113        })
114    }
115
116    /// Returns a handle for object operations in `bucket`. No request is sent.
117    #[must_use]
118    pub const fn from<'a>(&'a self, bucket: &'a str) -> Bucket<'a> {
119        Bucket::new(self, bucket)
120    }
121
122    /// Lists all buckets visible to the current credentials.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error on transport failure, non-success status, or an unexpected body.
127    pub async fn list_buckets(&self) -> Result<Vec<BucketInfo>, StorageError> {
128        Self::json(self.request(Method::GET, self.url(["bucket"]))).await
129    }
130
131    /// Fetches one bucket.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error on an invalid id, transport failure, non-success status, or an unexpected body.
136    pub async fn get_bucket(&self, id: &str) -> Result<BucketInfo, StorageError> {
137        let url = self.url(["bucket", path::bucket(id)?]);
138        Self::json(self.request(Method::GET, url)).await
139    }
140
141    /// Creates a bucket whose id and name are `id`.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error on an invalid id, transport failure, or non-success status.
146    pub async fn create_bucket(
147        &self,
148        id: &str,
149        options: &BucketOptions,
150    ) -> Result<(), StorageError> {
151        let body = BucketBody {
152            id: path::bucket(id)?,
153            name: id,
154            options,
155        };
156        Self::send(self.request(Method::POST, self.url(["bucket"])).json(&body)).await?;
157        Ok(())
158    }
159
160    /// Replaces the settings of a bucket.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error on an invalid id, transport failure, or non-success status.
165    pub async fn update_bucket(
166        &self,
167        id: &str,
168        options: &BucketOptions,
169    ) -> Result<(), StorageError> {
170        let url = self.url(["bucket", path::bucket(id)?]);
171        let body = BucketBody {
172            id,
173            name: id,
174            options,
175        };
176        Self::send(self.request(Method::PUT, url).json(&body)).await?;
177        Ok(())
178    }
179
180    /// Deletes all objects in a bucket and keeps the bucket.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error on an invalid id, transport failure, or non-success status.
185    pub async fn empty_bucket(&self, id: &str) -> Result<(), StorageError> {
186        let url = self.url(["bucket", path::bucket(id)?, "empty"]);
187        Self::send(
188            self.request(Method::POST, url)
189                .json(&serde_json::Map::new()),
190        )
191        .await?;
192        Ok(())
193    }
194
195    /// Deletes a bucket. The API rejects this when the bucket is not empty; call
196    /// [`StorageClient::empty_bucket`] first.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error on an invalid id, transport failure, or non-success status.
201    pub async fn delete_bucket(&self, id: &str) -> Result<(), StorageError> {
202        let url = self.url(["bucket", path::bucket(id)?]);
203        Self::send(self.request(Method::DELETE, url)).await?;
204        Ok(())
205    }
206
207    /// Builds `<base>/<segments...>`; each segment is percent-encoded once.
208    pub(crate) fn url<'a, I: IntoIterator<Item = &'a str>>(&self, segments: I) -> Url {
209        let mut url = self.base.clone();
210        // The constructor rejects cannot-be-a-base URLs, so this always succeeds.
211        if let Ok(mut path) = url.path_segments_mut() {
212            path.pop_if_empty().extend(segments);
213        }
214        url
215    }
216
217    pub(crate) fn request(&self, method: Method, url: Url) -> RequestBuilder {
218        self.http.request(method, url).headers(self.headers.clone())
219    }
220
221    pub(crate) async fn send(request: RequestBuilder) -> Result<Response, StorageError> {
222        let response = request.send().await?;
223        let status = response.status();
224        if status.is_success() {
225            return Ok(response);
226        }
227        let body = ApiErrorBody::decode(response.text().await?);
228        Err(StorageError::Api { status, body })
229    }
230
231    pub(crate) async fn json<T: DeserializeOwned>(
232        request: RequestBuilder,
233    ) -> Result<T, StorageError> {
234        let bytes = Self::send(request).await?.bytes().await?;
235        Ok(serde_json::from_slice(&bytes)?)
236    }
237}
238
239fn storage_base(project_url: &Url) -> Result<Url, StorageError> {
240    let base = project_url.join("storage/v1")?;
241    if base.cannot_be_a_base() {
242        return Err(StorageError::InvalidBaseUrl);
243    }
244    Ok(base)
245}
246
247fn sensitive(value: &str) -> Result<HeaderValue, StorageError> {
248    let mut header = HeaderValue::from_str(value)?;
249    header.set_sensitive(true);
250    Ok(header)
251}