Skip to main content

wowlab_supabase/
client.rs

1use std::{fmt, time::Duration};
2
3use reqwest::{Client, Method, RequestBuilder, Response};
4use serde::{Serialize, de::DeserializeOwned};
5use wowlab_types::constants::{
6    HTTP_CONNECT_TIMEOUT_SECS, HTTP_REQUEST_TIMEOUT_SECS, JSON_ERROR_PREVIEW_LEN, MS_PER_SECOND_U64,
7};
8
9use crate::{Result, SupabaseError};
10
11const DEFAULT_RETRY_AFTER_MS: u64 = 1000;
12
13fn require_env(name: &str) -> Result<String> {
14    std::env::var(name).map_err(|error| {
15        tracing::debug!(name, "Environment variable could not be read");
16
17        SupabaseError::env_var(name, error)
18    })
19}
20
21fn body_preview(body: &str) -> &str {
22    let mut end = body.len().min(JSON_ERROR_PREVIEW_LEN);
23
24    while !body.is_char_boundary(end) {
25        end -= 1;
26    }
27    // BOUNDS: `end` starts at or below `body.len()` and only decreases to a character boundary.
28
29    &body[..end]
30}
31
32/// HTTP client for the Supabase `PostgREST` and Storage APIs.
33#[derive(Clone)]
34pub struct SupabaseClient {
35    pub(crate) http: Client,
36    pub(crate) project_url: String,
37    pub(crate) api_key: String,
38}
39
40impl SupabaseClient {
41    /// Creates a client for a Supabase project and API key.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the underlying HTTP client cannot be built.
46    pub fn new(project_url: &str, api_key: &str) -> Result<Self> {
47        let http = match Client::builder()
48            .timeout(Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECS))
49            .connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
50            .build()
51        {
52            Ok(http) => http,
53            Err(error) => return Err(SupabaseError::client_build(error)),
54        };
55
56        Ok(Self {
57            http,
58            project_url: project_url.trim_end_matches('/').to_string(),
59            api_key: api_key.to_string(),
60        })
61    }
62
63    /// Creates a client from `SUPABASE_URL` and `SUPABASE_ANON_KEY`.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error when either variable is absent or the HTTP client cannot be built.
68    pub fn from_env() -> Result<Self> {
69        Self::from_env_key("SUPABASE_ANON_KEY")
70    }
71
72    /// Creates an administrative client from the Supabase environment variables.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error when `SUPABASE_URL` or `SUPABASE_SERVICE_ROLE_KEY` is absent, or the HTTP client cannot be built.
77    pub fn from_env_service_role() -> Result<Self> {
78        Self::from_env_key("SUPABASE_SERVICE_ROLE_KEY")
79    }
80
81    /// Fetches a `PostgREST` resource and decodes its JSON response.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error when the request fails, the server rejects it, or the response cannot be decoded as `T`.
86    pub async fn get_json<T>(&self, path: &str, schema: &str) -> Result<T>
87    where
88        T: DeserializeOwned,
89    {
90        let url = self.rest_url(path);
91        let response = self.send_get(&url, Some(schema)).await?;
92        let body = response.text().await?;
93
94        serde_json::from_str(&body).map_err(|error| {
95            let preview = body_preview(&body);
96
97            tracing::error!(%url, %error, body = preview, "JSON response decode failed");
98
99            SupabaseError::decode(url, preview, error)
100        })
101    }
102
103    /// Fetches a `PostgREST` resource as text.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error when the request fails or the server rejects it.
108    pub async fn get_text(&self, path: &str) -> Result<String> {
109        let url = self.rest_url(path);
110
111        Ok(self.send_get(&url, None).await?.text().await?)
112    }
113
114    /// Applies a partial update to a `PostgREST` resource.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error when serialization, transport, or the server response fails.
119    pub async fn patch<T>(&self, path: &str, body: &T) -> Result<()>
120    where
121        T: Serialize,
122    {
123        let url = self.rest_url(path);
124
125        tracing::debug!(%url, "Sending PATCH request");
126        let response = self
127            .request(Method::PATCH, &url)
128            .header("Content-Type", "application/json")
129            .json(body)
130            .send()
131            .await?;
132
133        check_response(response).await?;
134
135        Ok(())
136    }
137
138    pub(crate) fn request(&self, method: Method, url: &str) -> RequestBuilder {
139        self.http
140            .request(method, url)
141            .header("apikey", &self.api_key)
142            .bearer_auth(&self.api_key)
143    }
144
145    fn from_env_key(key_var: &str) -> Result<Self> {
146        let url = require_env("SUPABASE_URL")?;
147        let key = require_env(key_var)?;
148
149        Self::new(&url, &key)
150    }
151
152    fn rest_url(&self, path: &str) -> String {
153        format!("{}/rest/v1/{path}", self.project_url)
154    }
155
156    async fn send_get(&self, url: &str, schema: Option<&str>) -> Result<Response> {
157        tracing::debug!(%url, ?schema, "Sending GET request");
158        let mut request = self.request(Method::GET, url);
159
160        if let Some(schema) = schema {
161            request = request.header("Accept-Profile", schema);
162        }
163
164        check_response(request.send().await?).await
165    }
166}
167
168impl fmt::Debug for SupabaseClient {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.debug_struct("SupabaseClient")
171            .field("project_url", &self.project_url)
172            .field("api_key", &"<redacted>")
173            .finish_non_exhaustive()
174    }
175}
176
177pub(crate) async fn check_response(response: Response) -> Result<Response> {
178    let status = response.status();
179
180    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
181        let retry_after = response
182            .headers()
183            .get("retry-after")
184            .and_then(|value| value.to_str().ok())
185            .and_then(|value| value.parse::<u64>().ok());
186        let retry_after_ms = retry_after.map_or(DEFAULT_RETRY_AFTER_MS, |seconds| {
187            seconds.saturating_mul(MS_PER_SECOND_U64)
188        });
189
190        return Err(SupabaseError::rate_limited(retry_after_ms));
191    }
192
193    if !status.is_success() {
194        let message = response.text().await?;
195
196        return Err(SupabaseError::server(status.as_u16(), message));
197    }
198
199    Ok(response)
200}