Skip to main content

oxide_framework_supabase/
lib.rs

1use async_trait::async_trait;
2use oxide_framework_core::{App, FrameworkError, ReadinessCheck};
3use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
4use serde::Serialize;
5use serde_json::Value;
6use std::time::Duration;
7
8#[derive(Clone, Debug)]
9pub struct SupabaseConfig {
10    pub base_url: String,
11    pub api_key: String,
12    pub schema: String,
13    pub timeout_secs: u64,
14    pub strict: bool,
15}
16
17impl SupabaseConfig {
18    pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
19        Self {
20            base_url: base_url.into().trim_end_matches('/').to_string(),
21            api_key: api_key.into(),
22            schema: "public".to_string(),
23            timeout_secs: 10,
24            strict: false,
25        }
26    }
27
28    pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
29        self.schema = schema.into();
30        self
31    }
32
33    pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
34        self.timeout_secs = timeout_secs;
35        self
36    }
37
38    pub fn strict(mut self, strict: bool) -> Self {
39        self.strict = strict;
40        self
41    }
42}
43
44#[derive(Clone)]
45pub struct SupabaseClient {
46    cfg: SupabaseConfig,
47    http: reqwest::Client,
48}
49
50impl SupabaseClient {
51    pub fn new(cfg: SupabaseConfig) -> Self {
52        let mut headers = HeaderMap::new();
53        headers.insert(
54            "apikey",
55            HeaderValue::from_str(&cfg.api_key).unwrap_or_else(|_| HeaderValue::from_static("")),
56        );
57        headers.insert(
58            AUTHORIZATION,
59            HeaderValue::from_str(&format!("Bearer {}", cfg.api_key))
60                .unwrap_or_else(|_| HeaderValue::from_static("Bearer")),
61        );
62
63        let http = reqwest::Client::builder()
64            .default_headers(headers)
65            .timeout(Duration::from_secs(cfg.timeout_secs))
66            .build()
67            .expect("failed to build reqwest client for supabase");
68
69        Self { cfg, http }
70    }
71
72    pub async fn health_check(&self) -> Result<(), FrameworkError> {
73        let url = format!("{}/rest/v1/", self.cfg.base_url);
74        let res = self
75            .http
76            .get(url)
77            .header("accept-profile", &self.cfg.schema)
78            .send()
79            .await
80            .map_err(|e| FrameworkError::ReadinessFailed {
81                check: "supabase",
82                message: e.to_string(),
83            })?;
84
85        if res.status().is_success() {
86            Ok(())
87        } else {
88            Err(FrameworkError::ReadinessFailed {
89                check: "supabase",
90                message: format!("unexpected status {}", res.status()),
91            })
92        }
93    }
94
95    pub async fn select(&self, table: &str, query: &[(&str, &str)]) -> Result<Value, FrameworkError> {
96        let url = format!("{}/rest/v1/{}", self.cfg.base_url, table);
97        let res = self
98            .http
99            .get(url)
100            .header("accept-profile", &self.cfg.schema)
101            .query(query)
102            .send()
103            .await
104            .map_err(|e| FrameworkError::Internal(e.to_string()))?;
105
106        res.json::<Value>()
107            .await
108            .map_err(|e| FrameworkError::Internal(e.to_string()))
109    }
110
111    pub async fn insert<T: Serialize>(&self, table: &str, payload: &T) -> Result<Value, FrameworkError> {
112        let url = format!("{}/rest/v1/{}", self.cfg.base_url, table);
113        let res = self
114            .http
115            .post(url)
116            .header("content-profile", &self.cfg.schema)
117            .header("prefer", "return=representation")
118            .json(payload)
119            .send()
120            .await
121            .map_err(|e| FrameworkError::Internal(e.to_string()))?;
122
123        res.json::<Value>()
124            .await
125            .map_err(|e| FrameworkError::Internal(e.to_string()))
126    }
127
128    pub async fn rpc<T: Serialize>(&self, function: &str, payload: &T) -> Result<Value, FrameworkError> {
129        let url = format!("{}/rest/v1/rpc/{}", self.cfg.base_url, function);
130        let res = self
131            .http
132            .post(url)
133            .header("content-profile", &self.cfg.schema)
134            .json(payload)
135            .send()
136            .await
137            .map_err(|e| FrameworkError::Internal(e.to_string()))?;
138
139        res.json::<Value>()
140            .await
141            .map_err(|e| FrameworkError::Internal(e.to_string()))
142    }
143}
144
145#[derive(Clone)]
146struct SupabaseReady(SupabaseClient);
147
148#[async_trait]
149impl ReadinessCheck for SupabaseReady {
150    fn name(&self) -> &'static str {
151        "supabase"
152    }
153
154    async fn check(&self) -> Result<(), FrameworkError> {
155        self.0.health_check().await
156    }
157}
158
159pub trait AppSupabaseExt {
160    fn supabase(self, config: SupabaseConfig) -> Self;
161}
162
163impl AppSupabaseExt for App {
164    fn supabase(self, config: SupabaseConfig) -> Self {
165        let strict = config.strict;
166        let client = SupabaseClient::new(config);
167        let app = self.state(client.clone());
168        if strict {
169            app.readiness_check(SupabaseReady(client))
170        } else {
171            app
172        }
173    }
174}