// 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://.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 }