""" Supabase REST API Client (Self-Hosted) Handles all HTTP communication with Supabase Self-Hosted APIs. All requests go through Kong API Gateway on single base URL. APIs: - PostgREST (/rest/v1/) - Database CRUD - GoTrue (/auth/v1/) - Authentication - Storage (/storage/v1/) - File storage - Edge Functions (/functions/v1/) - Serverless - postgres-meta (/pg/) - Database admin """ import base64 import logging from typing import Any import aiohttp class SupabaseClient: """ Supabase Self-Hosted API client. All requests go through Kong gateway on single base URL. Uses JWT-based authentication with anon_key or service_role_key. """ def __init__( self, base_url: str, anon_key: str, service_role_key: str, meta_url: str | None = None, meta_auth: str | None = None, ): """ Initialize Supabase API client. Args: base_url: Supabase instance URL (Kong gateway or supabase.co) anon_key: Public API key (RLS protected). Optional — if empty, service_role_key is used for all calls. service_role_key: Admin API key (bypasses RLS). Required. meta_url: Optional direct postgres-meta URL (e.g. http://localhost:5555). When provided, postgres-meta calls hit this URL directly instead of the Kong /pg/ route. Useful when /pg/ is not exposed through Kong. meta_auth: Optional Basic Auth credentials for postgres-meta (username:password). When provided, requests to meta_base_url use Basic Auth instead of JWT. Recommended when postgres-meta is exposed via a public URL. """ self.base_url = base_url.rstrip("/") self.anon_key = anon_key self.service_role_key = service_role_key # postgres-meta base: custom URL or Kong /pg/ prefix self.meta_base_url = (meta_url or f"{self.base_url}/pg").rstrip("/") self.meta_auth = meta_auth # Initialize logger self.logger = logging.getLogger(f"SupabaseClient.{base_url}") def _get_headers( self, use_service_role: bool = False, additional_headers: dict | None = None ) -> dict[str, str]: """ Get request headers with API key authentication. Args: use_service_role: Use service_role_key (bypasses RLS) additional_headers: Additional headers to include Returns: Dict: Headers with authentication """ # If anon_key is not configured, fall back to service_role_key for all calls key = self.service_role_key if (use_service_role or not self.anon_key) else self.anon_key headers = { "apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json", "Accept": "application/json", } if additional_headers: headers.update(additional_headers) return headers def _get_meta_headers(self, additional_headers: dict | None = None) -> dict[str, str]: """Get headers for postgres-meta requests (Basic Auth if configured, else JWT).""" headers: dict[str, str] = { "Content-Type": "application/json", "Accept": "application/json", } if self.meta_auth: encoded = base64.b64encode(self.meta_auth.encode()).decode() headers["Authorization"] = f"Basic {encoded}" else: key = self.service_role_key headers["apikey"] = key headers["Authorization"] = f"Bearer {key}" if additional_headers: headers.update(additional_headers) return headers async def _head_request_headers( self, endpoint: str, params: dict | None = None, headers_override: dict | None = None, use_service_role: bool = False, base_url_override: str | None = None, ) -> dict[str, str]: """ Make HEAD request and return response headers. Used for operations like count_rows where the result is in a response header (Content-Range) rather than the body. """ url = f"{base_url_override or self.base_url}{endpoint}" headers = self._get_headers(use_service_role, headers_override) if params: params = {k: v for k, v in params.items() if v is not None} async with aiohttp.ClientSession() as session: async with session.head(url, params=params or None, headers=headers) as response: # Normalise to lowercase so callers can use consistent keys # (aiohttp CIMultiDictProxy is case-insensitive but dict() is not) return {k.lower(): v for k, v in response.headers.items()} async def request( self, method: str, endpoint: str, params: dict | None = None, json_data: Any = None, data: bytes | None = None, headers_override: dict | None = None, use_service_role: bool = False, base_url_override: str | None = None, ) -> Any: """ Make authenticated request to Supabase API. Args: method: HTTP method endpoint: API endpoint (with leading /) params: Query parameters json_data: JSON body data (dict or list) data: Raw binary data (for file uploads) headers_override: Override/add headers use_service_role: Use service_role_key base_url_override: Override base URL (used for postgres-meta calls) Returns: API response Raises: Exception: On API errors """ url = f"{base_url_override or self.base_url}{endpoint}" # Use Basic Auth headers for postgres-meta requests when meta_auth is configured if base_url_override and base_url_override == self.meta_base_url: headers = self._get_meta_headers(headers_override) else: headers = self._get_headers(use_service_role, headers_override) # Remove Content-Type for binary data if data is not None: headers.pop("Content-Type", None) # Filter None values from params and dict json bodies if params: params = {k: v for k, v in params.items() if v is not None} if json_data and isinstance(json_data, dict): json_data = {k: v for k, v in json_data.items() if v is not None} self.logger.debug(f"{method} {url}") async with aiohttp.ClientSession() as session: kwargs = { "method": method, "url": url, "headers": headers, } if params: kwargs["params"] = params if json_data is not None: kwargs["json"] = json_data if data: kwargs["data"] = data async with session.request(**kwargs) as response: self.logger.debug(f"Response status: {response.status}") # Handle 204 No Content if response.status == 204: return {"success": True} # Handle binary responses (file downloads) content_type = response.headers.get("Content-Type", "") if "application/json" not in content_type and response.status < 400: # Return binary data as base64 binary_data = await response.read() return { "data": base64.b64encode(binary_data).decode(), "content_type": content_type, "size": len(binary_data), } # Parse JSON response try: response_data = await response.json() except Exception: response_text = await response.text() if response.status >= 400: raise Exception( f"Supabase API error (status {response.status}): {response_text}" ) return {"success": True, "message": response_text} # Check for errors if response.status >= 400: error_msg = self._extract_error_message(response_data) raise Exception(f"Supabase API error (status {response.status}): {error_msg}") return response_data def _extract_error_message(self, response_data: Any) -> str: """Extract error message from various response formats.""" if isinstance(response_data, dict): # PostgREST error format — includes code, details, hint if "message" in response_data: parts = [response_data["message"]] if response_data.get("code"): parts.insert(0, f"[{response_data['code']}]") if response_data.get("details"): parts.append(f"Details: {response_data['details']}") if response_data.get("detail"): parts.append(f"Detail: {response_data['detail']}") if response_data.get("hint"): parts.append(f"Hint: {response_data['hint']}") return " | ".join(parts) # GoTrue error format if "error_description" in response_data: return response_data["error_description"] if "msg" in response_data: return response_data["msg"] # postgres-meta error format — "error" key with optional PG fields if "error" in response_data: parts = [str(response_data["error"])] if response_data.get("code"): parts.insert(0, f"[{response_data['code']}]") if response_data.get("hint"): parts.append(f"Hint: {response_data['hint']}") if response_data.get("detail"): parts.append(f"Detail: {response_data['detail']}") if response_data.get("position"): parts.append(f"Position: {response_data['position']}") return " | ".join(parts) return str(response_data) def _build_filter_params(self, filters: list[dict]) -> dict[str, str]: """ Convert a filter list to PostgREST query parameters. Handles special cases: - ``is`` operator with Python ``None`` → ``is.null`` - ``is`` operator with Python bool → ``is.true`` / ``is.false`` - ``in`` operator with a list → ``in.(a,b,c)`` - Any other operator with ``None`` value is skipped (prevents sending a bare ``col=eq.None`` which PostgREST would reject). """ params: dict[str, str] = {} for f in filters: col = f.get("column") op = f.get("operator", "eq") val = f.get("value") if not col: continue if val is None: # Only ``is`` / ``not.is`` accept null if op in ("is", "not.is"): val = "null" else: continue elif isinstance(val, bool): # Convert Python bool to lowercase string for PostgREST val = "true" if val else "false" elif op == "in" and isinstance(val, list): # PostgREST ``in`` format: status=in.(active,inactive) val = f"({','.join(str(v) for v in val)})" params[col] = f"{op}.{val}" return params # ===================== # POSTGREST (Database) # ===================== async def query_table( self, table: str, select: str = "*", filters: list[dict] | None = None, order: str | None = None, limit: int = 100, offset: int = 0, use_service_role: bool = False, ) -> list[dict]: """ Query data from a table. Args: table: Table name select: Columns to select filters: List of filter conditions order: Order by clause (e.g., "created_at.desc") limit: Maximum rows offset: Offset for pagination """ params: dict[str, Any] = {"select": select, "limit": limit, "offset": offset} if order: params["order"] = order if filters: params.update(self._build_filter_params(filters)) headers = {} # Request single objects as array headers["Accept"] = "application/json" return await self.request( "GET", f"/rest/v1/{table}", params=params, headers_override=headers, use_service_role=use_service_role, ) async def insert_rows( self, table: str, rows: list[dict], upsert: bool = False, on_conflict: str | None = None, use_service_role: bool = False, ) -> list[dict]: """Insert rows into a table.""" headers = {"Prefer": "return=representation"} upsert_params: dict[str, str] = {} if upsert: headers["Prefer"] = "return=representation,resolution=merge-duplicates" if on_conflict: # PostgREST expects on_conflict as a query parameter, not a header upsert_params["on_conflict"] = on_conflict return await self.request( "POST", f"/rest/v1/{table}", params=upsert_params or None, json_data=rows if isinstance(rows, list) else [rows], headers_override=headers, use_service_role=use_service_role, ) async def update_rows( self, table: str, data: dict, filters: list[dict], use_service_role: bool = False ) -> list[dict]: """Update rows matching filters.""" params = self._build_filter_params(filters) headers = {"Prefer": "return=representation"} return await self.request( "PATCH", f"/rest/v1/{table}", params=params, json_data=data, headers_override=headers, use_service_role=use_service_role, ) async def delete_rows( self, table: str, filters: list[dict], use_service_role: bool = False ) -> list[dict]: """Delete rows matching filters.""" params = self._build_filter_params(filters) headers = {"Prefer": "return=representation"} return await self.request( "DELETE", f"/rest/v1/{table}", params=params, headers_override=headers, use_service_role=use_service_role, ) async def execute_rpc( self, function_name: str, params: dict | None = None, use_service_role: bool = False ) -> Any: """Execute a stored procedure/function.""" return await self.request( "POST", f"/rest/v1/rpc/{function_name}", json_data=params or {}, use_service_role=use_service_role, ) async def count_rows( self, table: str, filters: list[dict] | None = None, use_service_role: bool = False ) -> int: """Count rows in a table using HEAD + Content-Range header.""" filter_params = self._build_filter_params(filters) if filters else {} response_headers = await self._head_request_headers( f"/rest/v1/{table}", params=filter_params or None, headers_override={"Prefer": "count=exact"}, use_service_role=use_service_role, ) # PostgREST returns count in Content-Range: 0-N/TOTAL or */TOTAL content_range = response_headers.get("content-range", "") if "/" in content_range: try: total = content_range.split("/")[-1] if total != "*": return int(total) except (ValueError, IndexError): pass return 0 # ===================== # POSTGRES-META (Admin) # ===================== # All postgres-meta requests use self.meta_base_url (default: {base_url}/pg). # Set META_URL env var for direct postgres-meta access (e.g., http://host:5555). # Note: postgres-meta /columns, /policies, /triggers do NOT support filtering # by table_name as a query param — we filter the results in Python instead. async def list_tables(self, schema: str = "public") -> list[dict]: """List all tables via postgres-meta.""" return await self.request( "GET", "/tables", params={"include_system_schemas": "false"}, use_service_role=True, base_url_override=self.meta_base_url, ) async def get_table_schema(self, table: str, schema: str = "public") -> dict: """Get table schema/columns via postgres-meta, filtered by table+schema in Python.""" all_columns = await self.request( "GET", "/columns", params={"include_system_schemas": "false"}, use_service_role=True, base_url_override=self.meta_base_url, ) # postgres-meta /columns does not support table_name filtering — filter here columns = [ col for col in (all_columns if isinstance(all_columns, list) else []) if col.get("table") == table and col.get("schema") == schema ] return {"table": table, "schema": schema, "columns": columns} async def list_schemas(self) -> list[dict]: """List all database schemas via postgres-meta.""" return await self.request( "GET", "/schemas", use_service_role=True, base_url_override=self.meta_base_url, ) async def list_extensions(self) -> list[dict]: """List installed extensions via postgres-meta.""" return await self.request( "GET", "/extensions", use_service_role=True, base_url_override=self.meta_base_url, ) async def list_policies(self, table: str | None = None) -> list[dict]: """List RLS policies via postgres-meta, optionally filtered by table name.""" all_policies = await self.request( "GET", "/policies", use_service_role=True, base_url_override=self.meta_base_url, ) # postgres-meta /policies does not support table_name filtering — filter here if table and isinstance(all_policies, list): return [p for p in all_policies if p.get("table") == table] return all_policies async def list_roles(self) -> list[dict]: """List database roles via postgres-meta.""" return await self.request( "GET", "/roles", use_service_role=True, base_url_override=self.meta_base_url, ) async def list_triggers(self, table: str | None = None) -> list[dict]: """List triggers via postgres-meta, optionally filtered by table name.""" all_triggers = await self.request( "GET", "/triggers", use_service_role=True, base_url_override=self.meta_base_url, ) # postgres-meta /triggers does not support table_name filtering — filter here if table and isinstance(all_triggers, list): return [t for t in all_triggers if t.get("table") == table] return all_triggers async def list_functions(self, schema: str = "public") -> list[dict]: """List database functions via postgres-meta.""" return await self.request( "GET", "/functions", params={"schema": schema}, use_service_role=True, base_url_override=self.meta_base_url, ) async def execute_sql(self, query: str) -> Any: """Execute raw SQL query via postgres-meta.""" return await self.request( "POST", "/query", json_data={"query": query}, use_service_role=True, base_url_override=self.meta_base_url, ) # ===================== # GOTRUE (Auth) # ===================== async def list_users(self, page: int = 1, per_page: int = 50) -> dict[str, Any]: """List all users (admin).""" return await self.request( "GET", "/auth/v1/admin/users", params={"page": page, "per_page": per_page}, use_service_role=True, ) async def get_user(self, user_id: str) -> dict[str, Any]: """Get user by ID.""" return await self.request("GET", f"/auth/v1/admin/users/{user_id}", use_service_role=True) async def create_user( self, email: str, password: str, email_confirm: bool = False, phone: str | None = None, user_metadata: dict | None = None, app_metadata: dict | None = None, ) -> dict[str, Any]: """Create a new user.""" data = {"email": email, "password": password, "email_confirm": email_confirm} if phone: data["phone"] = phone if user_metadata: data["user_metadata"] = user_metadata if app_metadata: data["app_metadata"] = app_metadata return await self.request( "POST", "/auth/v1/admin/users", json_data=data, use_service_role=True ) async def update_user( self, user_id: str, email: str | None = None, password: str | None = None, phone: str | None = None, email_confirm: bool | None = None, phone_confirm: bool | None = None, user_metadata: dict | None = None, app_metadata: dict | None = None, ban_duration: str | None = None, ) -> dict[str, Any]: """Update user.""" data = {} if email: data["email"] = email if password: data["password"] = password if phone: data["phone"] = phone if email_confirm is not None: data["email_confirm"] = email_confirm if phone_confirm is not None: data["phone_confirm"] = phone_confirm if user_metadata: data["user_metadata"] = user_metadata if app_metadata: data["app_metadata"] = app_metadata if ban_duration: data["ban_duration"] = ban_duration return await self.request( "PUT", f"/auth/v1/admin/users/{user_id}", json_data=data, use_service_role=True ) async def delete_user(self, user_id: str) -> dict[str, Any]: """Delete a user.""" return await self.request( "DELETE", f"/auth/v1/admin/users/{user_id}", use_service_role=True ) async def generate_link( self, email: str, link_type: str = "magiclink", redirect_to: str | None = None ) -> dict[str, Any]: """Generate magic link or recovery link.""" data = {"email": email, "type": link_type} if redirect_to: data["redirect_to"] = redirect_to return await self.request( "POST", "/auth/v1/admin/generate_link", json_data=data, use_service_role=True ) async def list_user_factors(self, user_id: str) -> list[dict]: """List user MFA factors.""" return await self.request( "GET", f"/auth/v1/admin/users/{user_id}/factors", use_service_role=True ) async def delete_user_factor(self, user_id: str, factor_id: str) -> dict: """Delete a user's MFA factor.""" return await self.request( "DELETE", f"/auth/v1/admin/users/{user_id}/factors/{factor_id}", use_service_role=True ) # ===================== # STORAGE # ===================== async def list_buckets(self) -> list[dict]: """List all storage buckets.""" return await self.request("GET", "/storage/v1/bucket", use_service_role=True) async def get_bucket(self, bucket_id: str) -> dict: """Get bucket details.""" return await self.request("GET", f"/storage/v1/bucket/{bucket_id}", use_service_role=True) async def create_bucket( self, name: str, public: bool = False, file_size_limit: int | None = None, allowed_mime_types: list[str] | None = None, ) -> dict: """Create a new bucket.""" data = {"name": name, "public": public} if file_size_limit: data["file_size_limit"] = file_size_limit if allowed_mime_types: data["allowed_mime_types"] = allowed_mime_types return await self.request( "POST", "/storage/v1/bucket", json_data=data, use_service_role=True ) async def update_bucket( self, bucket_id: str, public: bool | None = None, file_size_limit: int | None = None, allowed_mime_types: list[str] | None = None, ) -> dict: """Update bucket settings.""" data = {} if public is not None: data["public"] = public if file_size_limit: data["file_size_limit"] = file_size_limit if allowed_mime_types: data["allowed_mime_types"] = allowed_mime_types return await self.request( "PUT", f"/storage/v1/bucket/{bucket_id}", json_data=data, use_service_role=True ) async def delete_bucket(self, bucket_id: str) -> dict: """Delete a bucket.""" return await self.request( "DELETE", f"/storage/v1/bucket/{bucket_id}", use_service_role=True ) async def empty_bucket(self, bucket_id: str) -> dict: """Empty a bucket (delete all files).""" return await self.request( "POST", f"/storage/v1/bucket/{bucket_id}/empty", json_data={}, use_service_role=True, ) async def list_files( self, bucket: str, path: str = "", limit: int = 100, offset: int = 0 ) -> list[dict]: """List files in a bucket/path.""" data = {"prefix": path, "limit": limit, "offset": offset} return await self.request( "POST", f"/storage/v1/object/list/{bucket}", json_data=data, use_service_role=True ) async def upload_file( self, bucket: str, path: str, content: bytes, content_type: str = "application/octet-stream", upsert: bool = False, ) -> dict: """Upload a file.""" headers = {"Content-Type": content_type} if upsert: headers["x-upsert"] = "true" return await self.request( "POST", f"/storage/v1/object/{bucket}/{path}", data=content, headers_override=headers, use_service_role=True, ) async def download_file(self, bucket: str, path: str) -> dict: """Download a file (returns base64).""" return await self.request( "GET", f"/storage/v1/object/{bucket}/{path}", use_service_role=True ) async def delete_files(self, bucket: str, paths: list[str]) -> dict: """Delete files from bucket.""" return await self.request( "DELETE", f"/storage/v1/object/{bucket}", json_data={"prefixes": paths}, use_service_role=True, ) async def move_file(self, bucket: str, from_path: str, to_path: str) -> dict: """Move/rename a file.""" return await self.request( "POST", "/storage/v1/object/move", json_data={"bucketId": bucket, "sourceKey": from_path, "destinationKey": to_path}, use_service_role=True, ) async def get_public_url(self, bucket: str, path: str) -> str: """Get public URL for a file.""" return f"{self.base_url}/storage/v1/object/public/{bucket}/{path}" # ===================== # EDGE FUNCTIONS # ===================== async def invoke_function( self, function_name: str, body: dict | None = None, method: str = "POST" ) -> Any: """Invoke an Edge Function.""" return await self.request( method, f"/functions/v1/{function_name}", json_data=body, use_service_role=False, # Use anon key for functions ) # ===================== # HEALTH CHECK # ===================== async def health_check(self) -> dict[str, Any]: """Check Supabase instance health.""" results = {"healthy": True, "services": {}} # Check PostgREST try: await self.request("GET", "/rest/v1/", use_service_role=True) results["services"]["postgrest"] = "ok" except Exception as e: # A 404 on the PostgREST root still confirms connectivity — some versions # return 404 for the root endpoint while working normally for table queries. if "status 404" in str(e).lower() or "404" in str(e): results["services"]["postgrest"] = "ok" else: results["services"]["postgrest"] = f"error: {str(e)}" results["healthy"] = False # Check GoTrue try: await self.request("GET", "/auth/v1/health", use_service_role=False) results["services"]["gotrue"] = "ok" except Exception as e: results["services"]["gotrue"] = f"error: {str(e)}" results["healthy"] = False # Check Storage try: await self.list_buckets() results["services"]["storage"] = "ok" except Exception as e: results["services"]["storage"] = f"error: {str(e)}" results["healthy"] = False return results