Skip to main content

rp_supabase_functions/
lib.rs

1#![cfg_attr(doc, doc = include_str!("../README.md"))]
2mod error;
3
4pub use error::FunctionsError;
5pub use reqwest::Method;
6use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9pub use url;
10use url::Url;
11
12const RELAY_ERROR_HEADER: &str = "x-relay-error";
13const REGION_HEADER: &str = "x-region";
14
15/// Client for Supabase Edge Functions at `{project}/functions/v1/`.
16///
17/// Cloning is cheap: the HTTP connection pool is shared.
18#[derive(Debug, Clone)]
19pub struct FunctionsClient {
20    http: reqwest::Client,
21    base: Url,
22    headers: HeaderMap,
23}
24
25impl FunctionsClient {
26    /// Creates a client for the project base URL, for example `https://abc.supabase.co/`.
27    ///
28    /// The client sends `apikey` on every request. Legacy JWT keys are also sent as
29    /// `Authorization: Bearer <api_key>`; new-format keys (`sb_publishable_...`, `sb_secret_...`) are not.
30    ///
31    /// The built-in HTTP client follows redirects only within the same origin (scheme, host,
32    /// port), up to 10 hops, so credentials never reach another host.
33    ///
34    /// # Errors
35    ///
36    /// Returns an error when the URL cannot hold a path, the key is not a valid header value,
37    /// or the HTTP client cannot be built.
38    pub fn new(project_url: &Url, api_key: &str) -> Result<Self, FunctionsError> {
39        Self::new_with_client(project_url, api_key, default_client()?)
40    }
41
42    /// Same as [`Self::new`], but reuses the given HTTP client and its connection pool.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error when the URL cannot hold a path or the key is not a valid header value.
47    pub fn new_with_client(
48        project_url: &Url,
49        api_key: &str,
50        http: reqwest::Client,
51    ) -> Result<Self, FunctionsError> {
52        if project_url.cannot_be_a_base() {
53            return Err(FunctionsError::UrlNotBase);
54        }
55        let mut root = project_url.clone();
56        if !root.path().ends_with('/') {
57            let path = format!("{}/", root.path());
58            root.set_path(&path);
59        }
60        let base = root.join("functions/v1/")?;
61        let mut headers = HeaderMap::new();
62        headers.insert("apikey", sensitive(api_key)?);
63        if !is_new_format_key(api_key) {
64            headers.insert(AUTHORIZATION, sensitive(&format!("Bearer {api_key}"))?);
65        }
66        Ok(Self {
67            http,
68            base,
69            headers,
70        })
71    }
72
73    /// Returns a copy of the client that sends `Authorization: Bearer <token>`.
74    ///
75    /// Use this to call functions as a signed-in user.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error when the token is not a valid header value.
80    pub fn with_access_token(&self, token: &str) -> Result<Self, FunctionsError> {
81        let mut next = self.clone();
82        next.headers
83            .insert(AUTHORIZATION, sensitive(&format!("Bearer {token}"))?);
84        Ok(next)
85    }
86
87    /// Starts a request to the function `name`. The default method is `POST`.
88    ///
89    /// The name must be non-empty, must not be `.` or `..`, and must not contain `/`, tab,
90    /// CR, or LF. The builder reports an
91    /// invalid name when you call [`InvokeBuilder::send`].
92    pub fn invoke(&self, name: &str) -> InvokeBuilder {
93        let url = if is_valid_function_name(name) {
94            let mut url = self.base.clone();
95            url.path_segments_mut()
96                .map_err(|()| FunctionsError::UrlNotBase)
97                .map(|mut segments| {
98                    segments.pop_if_empty().push(name);
99                })
100                .map(|()| url)
101        } else {
102            Err(FunctionsError::InvalidFunctionName(name.to_owned()))
103        };
104        InvokeBuilder {
105            http: self.http.clone(),
106            state: url.map(|url| Request {
107                url,
108                method: Method::POST,
109                headers: self.headers.clone(),
110                body: None,
111            }),
112        }
113    }
114}
115
116fn default_client() -> Result<reqwest::Client, reqwest::Error> {
117    reqwest::Client::builder()
118        .redirect(reqwest::redirect::Policy::custom(|attempt| {
119            let same_origin = attempt
120                .previous()
121                .last()
122                .is_some_and(|prev| prev.origin() == attempt.url().origin());
123            if !same_origin {
124                attempt.stop()
125            } else if attempt.previous().len() > 10 {
126                attempt.error("too many redirects")
127            } else {
128                attempt.follow()
129            }
130        }))
131        .build()
132}
133
134fn is_new_format_key(api_key: &str) -> bool {
135    api_key.starts_with("sb_publishable_") || api_key.starts_with("sb_secret_")
136}
137
138fn is_valid_function_name(name: &str) -> bool {
139    !matches!(name, "" | "." | "..") && !name.contains(['/', '\t', '\r', '\n'])
140}
141
142fn sensitive(value: &str) -> Result<HeaderValue, FunctionsError> {
143    let mut header = HeaderValue::from_str(value)?;
144    header.set_sensitive(true);
145    Ok(header)
146}
147
148#[derive(Debug)]
149struct Request {
150    url: Url,
151    method: Method,
152    headers: HeaderMap,
153    body: Option<Vec<u8>>,
154}
155
156/// Request builder returned by [`FunctionsClient::invoke`].
157///
158/// Builder errors (invalid name, header, or JSON body) surface from [`Self::send`].
159#[derive(Debug)]
160#[must_use = "call `send`, `fetch`, or `text` to run the request"]
161pub struct InvokeBuilder {
162    http: reqwest::Client,
163    state: Result<Request, FunctionsError>,
164}
165
166impl InvokeBuilder {
167    fn update(mut self, apply: impl FnOnce(&mut Request) -> Result<(), FunctionsError>) -> Self {
168        if let Ok(request) = self.state.as_mut() {
169            if let Err(error) = apply(request) {
170                self.state = Err(error);
171            }
172        }
173        self
174    }
175
176    /// Sets the HTTP method. The default is `POST`.
177    pub fn method(self, method: Method) -> Self {
178        self.update(|request| {
179            request.method = method;
180            Ok(())
181        })
182    }
183
184    /// Sends `value` as a JSON body with `Content-Type: application/json`.
185    pub fn json<T: Serialize + ?Sized>(self, value: &T) -> Self {
186        self.update(|request| {
187            let bytes = serde_json::to_vec(value).map_err(FunctionsError::Serialize)?;
188            request
189                .headers
190                .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
191            request.body = Some(bytes);
192            Ok(())
193        })
194    }
195
196    /// Sends raw bytes with the given `Content-Type`.
197    pub fn body<B: Into<Vec<u8>>>(self, bytes: B, content_type: &str) -> Self {
198        self.update(|request| {
199            request
200                .headers
201                .insert(CONTENT_TYPE, HeaderValue::from_str(content_type)?);
202            request.body = Some(bytes.into());
203            Ok(())
204        })
205    }
206
207    /// Sets a request header. A later call with the same name replaces the value.
208    pub fn header(self, name: &str, value: &str) -> Self {
209        self.update(|request| {
210            let name = HeaderName::from_bytes(name.as_bytes())?;
211            let mut value = HeaderValue::from_str(value)?;
212            if name == AUTHORIZATION || name.as_str() == "apikey" {
213                value.set_sensitive(true);
214            }
215            request.headers.insert(name, value);
216            Ok(())
217        })
218    }
219
220    /// Runs the function in a specific region, for example `us-east-1`, via the `x-region` header.
221    pub fn region(self, region: &str) -> Self {
222        self.header(REGION_HEADER, region)
223    }
224
225    /// Sends the request and returns the response when the status is 2xx.
226    ///
227    /// # Errors
228    ///
229    /// - [`FunctionsError::Relay`] when the response has `x-relay-error: true`.
230    /// - [`FunctionsError::Http`] for any other non-2xx status.
231    /// - Builder and transport errors.
232    pub async fn send(self) -> Result<reqwest::Response, FunctionsError> {
233        let request = self.state?;
234        let mut builder = self
235            .http
236            .request(request.method, request.url)
237            .headers(request.headers);
238        if let Some(body) = request.body {
239            builder = builder.body(body);
240        }
241        let response = builder.send().await?;
242        let relay = response
243            .headers()
244            .get(RELAY_ERROR_HEADER)
245            .is_some_and(|value| value.as_bytes() == b"true");
246        let status = response.status();
247        if relay {
248            let body = response.text().await?;
249            return Err(FunctionsError::Relay { status, body });
250        }
251        if !status.is_success() {
252            let body = response.text().await?;
253            return Err(FunctionsError::Http { status, body });
254        }
255        Ok(response)
256    }
257
258    /// Sends the request and decodes the JSON response body as `T`.
259    ///
260    /// # Errors
261    ///
262    /// Errors from [`Self::send`], plus [`FunctionsError::Decode`] for invalid JSON.
263    pub async fn fetch<T: DeserializeOwned>(self) -> Result<T, FunctionsError> {
264        let bytes = self.send().await?.bytes().await?;
265        serde_json::from_slice(&bytes).map_err(FunctionsError::Decode)
266    }
267
268    /// Sends the request and returns the response body as text.
269    ///
270    /// # Errors
271    ///
272    /// Errors from [`Self::send`].
273    pub async fn text(self) -> Result<String, FunctionsError> {
274        Ok(self.send().await?.text().await?)
275    }
276}