Spaces:
Running
Running
restructure repo into production layout; add whatsapp-service, tests, ddl, docs, scripts; scrub hardcoded secrets
83d6851 | // 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 | |
| } |