wowlab_supabase/
client.rs1use 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 &body[..end]
30}
31
32#[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 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 pub fn from_env() -> Result<Self> {
69 Self::from_env_key("SUPABASE_ANON_KEY")
70 }
71
72 pub fn from_env_service_role() -> Result<Self> {
78 Self::from_env_key("SUPABASE_SERVICE_ROLE_KEY")
79 }
80
81 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 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 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}