validops-east-1's picture
restructure repo into production layout; add whatsapp-service, tests, ddl, docs, scripts; scrub hardcoded secrets
83d6851
Raw
History Blame Contribute Delete
6.35 kB
// Package supabase is a client for the Supabase PostgREST API.
// It talks to the /rest/v1 endpoint using the service-role key, which bypasses
// row level security so the backend can read/write the app tables.
package supabase
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client wraps the PostgREST endpoint exposed by Supabase.
type Client struct {
baseURL string // e.g. https://<project>.supabase.co/rest/v1
serviceKey string
http *http.Client
}
// New builds a PostgREST client from the Supabase project URL and service role key.
func New(supabaseURL, serviceKey string) *Client {
base := strings.TrimSuffix(supabaseURL, "/")
if !strings.HasSuffix(base, "/rest/v1") {
base += "/rest/v1"
}
return &Client{
baseURL: base,
serviceKey: serviceKey,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) do(ctx context.Context, method, path string, body interface{}, prefer string, out interface{}) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return err
}
req.Header.Set("apikey", c.serviceKey)
req.Header.Set("Authorization", "Bearer "+c.serviceKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if prefer != "" {
req.Header.Set("Prefer", prefer)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &APIError{Status: resp.StatusCode, Body: string(data), Table: tableFromPath(path)}
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("supabase: failed to decode response for %s: %v", path, err)
}
}
return nil
}
// Query builds PostgREST query parameters (e.g. ?id=eq.xxx&select=...).
type Query struct {
values url.Values
}
// NewQuery creates an empty query builder.
func NewQuery() *Query {
return &Query{values: url.Values{}}
}
// Eq adds an equality filter: column=eq.value.
func (q *Query) Eq(column, value string) *Query {
q.values.Set(column, "eq."+value)
return q
}
// Neq adds a not-equal filter: column=neq.value.
func (q *Query) Neq(column, value string) *Query {
q.values.Set(column, "neq."+value)
return q
}
// Gte adds a greater-than-or-equal filter.
func (q *Query) Gte(column, value string) *Query {
q.values.Set(column, "gte."+value)
return q
}
// Select restricts returned columns.
func (q *Query) Select(cols ...string) *Query {
q.values.Set("select", strings.Join(cols, ","))
return q
}
// Order changes the result ordering.
func (q *Query) Order(column string, desc bool) *Query {
dir := "asc"
if desc {
dir = "desc"
}
q.values.Set("order", column+"."+dir)
return q
}
// Limit caps the number of returned rows.
func (q *Query) Limit(n int) *Query {
q.values.Set("limit", fmt.Sprintf("%d", n))
return q
}
func (q *Query) Encode() string { return q.values.Encode() }
// Table offers CRUD operations against a single table.
type Table struct {
c *Client
name string
}
// Table returns a Table handle for the given table name.
func (c *Client) Table(name string) *Table {
return &Table{c: c, name: name}
}
// SelectOne fetches a single row. It returns (false, nil) if no row matches.
func (t *Table) SelectOne(ctx context.Context, query *Query, out interface{}) (bool, error) {
var rows []json.RawMessage
effective := NewQuery()
for k, vals := range query.values {
for _, v := range vals {
effective.values.Add(k, v)
}
}
effective.values.Set("limit", "1")
if err := t.c.do(ctx, http.MethodGet, "/"+t.name+"?"+effective.Encode(), nil, "", &rows); err != nil {
return false, err
}
if len(rows) == 0 {
return false, nil
}
if err := json.Unmarshal(rows[0], out); err != nil {
return false, err
}
return true, nil
}
// Select reads all matching rows into out (a slice pointer).
func (t *Table) Select(ctx context.Context, query *Query, out interface{}) error {
return t.c.do(ctx, http.MethodGet, "/"+t.name+"?"+query.Encode(), nil, "", out)
}
// Insert inserts one or more rows (out receives inserted rows).
func (t *Table) Insert(ctx context.Context, body interface{}, prefer string, out interface{}) error {
return t.c.do(ctx, http.MethodPost, "/"+t.name, body, prefer, out)
}
// Upsert inserts rows, updating conflicting columns. conflictCols are the unique keyed
// columns used by PostgREST's on_conflict resolution. PostgREST expects
// on_conflict as a query parameter and resolution in the Prefer header:
//
// POST /table?on_conflict=col1,col2
// Prefer: resolution=merge-duplicates
func (t *Table) Upsert(ctx context.Context, body interface{}, conflictCols []string, out interface{}) error {
path := "/" + t.name
prefer := "resolution=ignore-duplicates"
if len(conflictCols) > 0 {
prefer = "resolution=merge-duplicates"
path += "?on_conflict=" + url.QueryEscape(strings.Join(conflictCols, ","))
}
return t.c.do(ctx, http.MethodPost, path, body, prefer, out)
}
// Update patches rows matching the query.
func (t *Table) Update(ctx context.Context, query *Query, body interface{}) error {
return t.c.do(ctx, http.MethodPatch, "/"+t.name+"?"+query.Encode(), body, "return=representation", nil)
}
// Delete removes rows matching the query.
func (t *Table) Delete(ctx context.Context, query *Query) error {
return t.c.do(ctx, http.MethodDelete, "/"+t.name+"?"+query.Encode(), nil, "", nil)
}
// RPC calls a stored function.
func (c *Client) RPC(ctx context.Context, fn string, body, out interface{}) error {
return c.do(ctx, http.MethodPost, "/rpc/"+fn, body, "", out)
}
// APIError represents a non-2xx PostgREST response.
type APIError struct {
Status int
Body string
Table string
}
func (e *APIError) Error() string {
msg := e.Body
if msg == "" {
msg = http.StatusText(e.Status)
}
return fmt.Sprintf("supabase: %s: HTTP %d: %s", e.Table, e.Status, strings.TrimSpace(msg))
}
func tableFromPath(path string) string {
seg := strings.Split(strings.Trim(path, "/"), "/")
if len(seg) > 0 {
return seg[0]
}
return path
}