oxide-framework-supabase 0.2.0

Supabase (PostgREST/RPC) integration for the Oxide web framework.
Documentation
use async_trait::async_trait;
use oxide_framework_core::{App, FrameworkError, ReadinessCheck};
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
use serde::Serialize;
use serde_json::Value;
use std::time::Duration;

#[derive(Clone, Debug)]
pub struct SupabaseConfig {
    pub base_url: String,
    pub api_key: String,
    pub schema: String,
    pub timeout_secs: u64,
    pub strict: bool,
}

impl SupabaseConfig {
    pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
        Self {
            base_url: base_url.into().trim_end_matches('/').to_string(),
            api_key: api_key.into(),
            schema: "public".to_string(),
            timeout_secs: 10,
            strict: false,
        }
    }

    pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
        self.schema = schema.into();
        self
    }

    pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
        self.timeout_secs = timeout_secs;
        self
    }

    pub fn strict(mut self, strict: bool) -> Self {
        self.strict = strict;
        self
    }
}

#[derive(Clone)]
pub struct SupabaseClient {
    cfg: SupabaseConfig,
    http: reqwest::Client,
}

impl SupabaseClient {
    pub fn new(cfg: SupabaseConfig) -> Self {
        let mut headers = HeaderMap::new();
        headers.insert(
            "apikey",
            HeaderValue::from_str(&cfg.api_key).unwrap_or_else(|_| HeaderValue::from_static("")),
        );
        headers.insert(
            AUTHORIZATION,
            HeaderValue::from_str(&format!("Bearer {}", cfg.api_key))
                .unwrap_or_else(|_| HeaderValue::from_static("Bearer")),
        );

        let http = reqwest::Client::builder()
            .default_headers(headers)
            .timeout(Duration::from_secs(cfg.timeout_secs))
            .build()
            .expect("failed to build reqwest client for supabase");

        Self { cfg, http }
    }

    pub async fn health_check(&self) -> Result<(), FrameworkError> {
        let url = format!("{}/rest/v1/", self.cfg.base_url);
        let res = self
            .http
            .get(url)
            .header("accept-profile", &self.cfg.schema)
            .send()
            .await
            .map_err(|e| FrameworkError::ReadinessFailed {
                check: "supabase",
                message: e.to_string(),
            })?;

        if res.status().is_success() {
            Ok(())
        } else {
            Err(FrameworkError::ReadinessFailed {
                check: "supabase",
                message: format!("unexpected status {}", res.status()),
            })
        }
    }

    pub async fn select(&self, table: &str, query: &[(&str, &str)]) -> Result<Value, FrameworkError> {
        let url = format!("{}/rest/v1/{}", self.cfg.base_url, table);
        let res = self
            .http
            .get(url)
            .header("accept-profile", &self.cfg.schema)
            .query(query)
            .send()
            .await
            .map_err(|e| FrameworkError::Internal(e.to_string()))?;

        res.json::<Value>()
            .await
            .map_err(|e| FrameworkError::Internal(e.to_string()))
    }

    pub async fn insert<T: Serialize>(&self, table: &str, payload: &T) -> Result<Value, FrameworkError> {
        let url = format!("{}/rest/v1/{}", self.cfg.base_url, table);
        let res = self
            .http
            .post(url)
            .header("content-profile", &self.cfg.schema)
            .header("prefer", "return=representation")
            .json(payload)
            .send()
            .await
            .map_err(|e| FrameworkError::Internal(e.to_string()))?;

        res.json::<Value>()
            .await
            .map_err(|e| FrameworkError::Internal(e.to_string()))
    }

    pub async fn rpc<T: Serialize>(&self, function: &str, payload: &T) -> Result<Value, FrameworkError> {
        let url = format!("{}/rest/v1/rpc/{}", self.cfg.base_url, function);
        let res = self
            .http
            .post(url)
            .header("content-profile", &self.cfg.schema)
            .json(payload)
            .send()
            .await
            .map_err(|e| FrameworkError::Internal(e.to_string()))?;

        res.json::<Value>()
            .await
            .map_err(|e| FrameworkError::Internal(e.to_string()))
    }
}

#[derive(Clone)]
struct SupabaseReady(SupabaseClient);

#[async_trait]
impl ReadinessCheck for SupabaseReady {
    fn name(&self) -> &'static str {
        "supabase"
    }

    async fn check(&self) -> Result<(), FrameworkError> {
        self.0.health_check().await
    }
}

pub trait AppSupabaseExt {
    fn supabase(self, config: SupabaseConfig) -> Self;
}

impl AppSupabaseExt for App {
    fn supabase(self, config: SupabaseConfig) -> Self {
        let strict = config.strict;
        let client = SupabaseClient::new(config);
        let app = self.state(client.clone());
        if strict {
            app.readiness_check(SupabaseReady(client))
        } else {
            app
        }
    }
}