oxide_framework_supabase/
lib.rs1use 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}