Files
social-app/backend/src/services/base/supabase.py
T

291 lines
11 KiB
Python

from __future__ import annotations
import asyncio
from typing import Any
from supabase import create_client
from storage3.exceptions import StorageApiError
from core.config.settings import SupabaseSettings, config
from core.config.settings import config as app_config
from .service_interface import BaseServiceProvider, register_service_instance
class SupabaseService(BaseServiceProvider):
def __init__(self, settings: SupabaseSettings | None = None) -> None:
super().__init__("supabase")
self._settings = settings or config.supabase
self._client: Any = None
self._admin_client: Any = None
async def initialize(self, **_: Any) -> bool:
try:
self._init_clients()
await self._ensure_storage_bucket()
self._set_initialized(True)
self.logger.info("Supabase service initialized")
return True
except Exception as exc: # noqa: BLE001
self.logger.warning(
"Supabase service initialization failed", error=str(exc)
)
self._client = None
self._admin_client = None
self._set_initialized(False)
return False
async def close(self) -> bool:
self._client = None
self._admin_client = None
self._set_initialized(False)
self.logger.info("Supabase service closed")
return True
async def health_check(self) -> dict[str, Any]:
client = self._client
admin_client = self._admin_client
if client is None or admin_client is None:
return {"status": "unhealthy", "details": {"error": "not initialized"}}
try:
await asyncio.to_thread(client.auth.get_session)
await asyncio.to_thread(
admin_client.auth.admin.list_users, page=1, per_page=1
)
return {
"status": "healthy",
"details": {
"anon_client": "ready",
"admin_client": "ready",
},
}
except Exception as exc: # noqa: BLE001
self.logger.warning("Supabase health check failed", error=str(exc))
return {"status": "unhealthy", "details": {"error": str(exc)}}
def get_client(self) -> Any:
return self._require_client()
def get_admin_client(self) -> Any:
return self._require_admin_client()
def _require_client(self) -> Any:
if self._client is None or self._admin_client is None:
self._init_clients()
self._set_initialized(True)
self.logger.info("Supabase service lazily initialized")
client = self._client
if client is None:
raise RuntimeError("Supabase client is not initialized")
return client
def _require_admin_client(self) -> Any:
if self._client is None or self._admin_client is None:
self._init_clients()
self._set_initialized(True)
self.logger.info("Supabase service lazily initialized")
admin_client = self._admin_client
if admin_client is None:
raise RuntimeError("Supabase admin client is not initialized")
return admin_client
def _init_clients(self) -> None:
self._client = create_client(
self._settings.url,
self._settings.anon_key,
)
self._admin_client = create_client(
self._settings.url,
self._settings.service_role_key,
)
async def _ensure_storage_bucket(self) -> None:
bucket_name = app_config.storage.bucket
storage = getattr(self._admin_client, "storage", None)
if storage is None:
self.logger.warning("Storage client unavailable, skipping bucket check")
return
get_bucket = getattr(storage, "get_bucket", None)
if not callable(get_bucket):
self.logger.warning("Storage get_bucket unavailable, skipping bucket check")
return
def _check_and_create() -> None:
try:
get_bucket(bucket_name)
self.logger.debug("Storage bucket already exists", bucket=bucket_name)
except Exception: # noqa: BLE001
create_bucket = getattr(storage, "create_bucket", None)
if not callable(create_bucket):
self.logger.warning(
"Storage create_bucket unavailable, skipping bucket creation"
)
return
try:
create_bucket(bucket_name, options={"public": False})
self.logger.info("Storage bucket created", bucket=bucket_name)
except Exception as exc: # noqa: BLE001
msg = str(exc).lower()
if "already exists" in msg or "duplicate" in msg:
self.logger.debug(
"Storage bucket already exists (race)", bucket=bucket_name
)
return
self.logger.warning(
"Failed to create storage bucket",
bucket=bucket_name,
error=str(exc),
)
await asyncio.to_thread(_check_and_create)
def _get_storage(self) -> Any:
"""Get the storage client from admin client."""
client = self.get_admin_client()
storage = getattr(client, "storage", None)
if storage is None:
raise RuntimeError("Supabase storage client unavailable")
return storage
def _get_bucket_client(self, bucket: str) -> Any:
"""Get a bucket client for the specified bucket."""
storage = self._get_storage()
from_bucket = getattr(storage, "from_", None)
if not callable(from_bucket):
raise RuntimeError("Supabase storage bucket accessor unavailable")
return from_bucket(bucket)
def _validate_bucket(self, bucket: str) -> None:
"""Validate that the bucket matches the configured bucket."""
expected = app_config.storage.bucket
if bucket != expected:
raise RuntimeError("Invalid attachment bucket")
def _ensure_bucket_client(self, bucket: str) -> Any:
"""Validate bucket and return authenticated bucket client."""
self._validate_bucket(bucket)
return self._get_bucket_client(bucket)
def _is_bucket_not_found_error(self, exc: Exception) -> bool:
"""Check if the exception indicates a bucket was not found."""
if isinstance(exc, StorageApiError):
message = str(exc).lower()
return "bucket" in message and "not found" in message
message = str(exc).lower()
return "bucket" in message and "not found" in message
async def upload_bytes(
self,
*,
bucket: str,
path: str,
content: bytes,
content_type: str,
) -> str:
def _upload() -> object:
bucket_client = self._ensure_bucket_client(bucket)
upload = getattr(bucket_client, "upload", None)
if not callable(upload):
raise RuntimeError("Supabase storage upload is unavailable")
return upload(
path,
content,
{
"content-type": content_type,
"upsert": "true",
},
)
try:
await asyncio.to_thread(_upload)
except Exception as exc: # noqa: BLE001
if not self._is_bucket_not_found_error(exc):
raise
await self._ensure_bucket_exists(bucket=bucket)
await asyncio.to_thread(_upload)
return path
async def _ensure_bucket_exists(self, *, bucket: str) -> None:
def _ensure() -> None:
storage = self._get_storage()
get_bucket = getattr(storage, "get_bucket", None)
if not callable(get_bucket):
raise RuntimeError("Supabase storage get_bucket is unavailable")
try:
get_bucket(bucket)
except Exception as exc: # noqa: BLE001
msg = str(exc).lower()
if "bucket" in msg and "not found" in msg:
raise RuntimeError(f"Storage bucket '{bucket}' does not exist")
raise
await asyncio.to_thread(_ensure)
async def download_bytes(self, *, bucket: str, path: str) -> bytes:
def _download() -> object:
bucket_client = self._ensure_bucket_client(bucket)
download = getattr(bucket_client, "download", None)
if not callable(download):
raise RuntimeError("Supabase storage download is unavailable")
return download(path)
raw = await asyncio.to_thread(_download)
if isinstance(raw, bytes):
return raw
if isinstance(raw, bytearray):
return bytes(raw)
if isinstance(raw, memoryview):
return raw.tobytes()
raise RuntimeError("Invalid attachment payload")
async def create_signed_url(
self,
*,
bucket: str,
path: str,
expires_in_seconds: int,
) -> str:
def _create_signed_url() -> object:
bucket_client = self._ensure_bucket_client(bucket)
signer = getattr(bucket_client, "create_signed_url", None)
if not callable(signer):
raise RuntimeError("Supabase storage signed url is unavailable")
return signer(path, expires_in_seconds)
raw = await asyncio.to_thread(_create_signed_url)
if isinstance(raw, str):
return raw
if isinstance(raw, dict):
signed_url = raw.get("signedURL") or raw.get("signedUrl") or raw.get("url")
if isinstance(signed_url, str) and signed_url:
return signed_url
raise RuntimeError("Invalid signed url payload")
def parse_signed_url(self, url: str) -> tuple[str, str]:
from urllib.parse import urlparse
parsed = urlparse(url)
path_parts = parsed.path.strip("/").split("/")
if (
len(path_parts) < 4
or path_parts[0] != "storage"
or path_parts[1] != "v1"
or path_parts[2] != "object"
or path_parts[3] != "sign"
):
raise RuntimeError("Invalid signed URL format")
bucket = path_parts[4]
path = "/".join(path_parts[5:])
return bucket, path
supabase_service: SupabaseService = register_service_instance(
"supabase", SupabaseService()
)
__all__ = ["SupabaseService", "supabase_service"]