app.bettersight.io/backend/repositories/pocketbase_client.py

173 lines
6.7 KiB
Python

import os
import time
import logging
import requests
logger = logging.getLogger(__name__)
POCKETBASE_URL = os.getenv('POCKETBASE_URL', 'http://pocketbase:8090')
ADMIN_EMAIL = os.getenv('POCKETBASE_ADMIN_EMAIL')
ADMIN_PASSWORD = os.getenv('POCKETBASE_ADMIN_PASSWORD')
class PocketBaseError(Exception):
"""Raised when a PocketBase REST call fails after the request completes."""
pass
class PocketBaseClient:
"""
Thin REST wrapper around a self-hosted PocketBase instance.
This is the ONLY place in the codebase that knows PocketBase's HTTP
API shape (auth endpoint, record CRUD paths, filter syntax). Every
repository in repositories/ uses this client instead of calling
`requests` directly, so a PocketBase version/API change is a
one-file fix.
Data flow:
collection + operation + params →
ensure valid superuser token (auto re-auth on expiry) →
HTTP call to PocketBase REST API →
JSON response → plain dict/list returned to the calling repository
"""
def __init__(self):
self._token = None
self._token_expires_at = 0
def _authenticate(self):
"""
Authenticates as a PocketBase superuser and caches the token.
Data flow:
POCKETBASE_ADMIN_EMAIL + PASSWORD →
POST /api/collections/_superusers/auth-with-password →
token cached in memory with a conservative TTL
"""
response = requests.post(
f'{POCKETBASE_URL}/api/collections/_superusers/auth-with-password',
json={'identity': ADMIN_EMAIL, 'password': ADMIN_PASSWORD},
timeout=10
)
if response.status_code != 200:
raise PocketBaseError(
f'PocketBase admin auth failed: {response.status_code} {response.text[:200]}'
)
data = response.json()
self._token = data['token']
# PocketBase admin tokens are long-lived (default 7 days) — refresh
# an hour early so we never get caught by an in-flight 401.
self._token_expires_at = time.time() + (6 * 24 * 3600)
def _headers(self):
# Re-authenticate lazily — only when there is no cached token or
# it is past our conservative expiry estimate.
if not self._token or time.time() >= self._token_expires_at:
self._authenticate()
return {'Authorization': self._token}
def _request(self, method, path, **kwargs):
"""
Issues one authenticated request, retrying once on a 401 in case
the cached token was revoked or expired earlier than estimated.
Data flow:
method + path + kwargs → authenticated request →
on 401: force re-auth → retry once →
non-2xx after retry: raise PocketBaseError →
2xx: return parsed JSON (or None for 204/empty bodies)
"""
url = f'{POCKETBASE_URL}{path}'
response = requests.request(method, url, headers=self._headers(), timeout=15, **kwargs)
if response.status_code == 401:
self._token = None
response = requests.request(method, url, headers=self._headers(), timeout=15, **kwargs)
if response.status_code >= 400:
raise PocketBaseError(
f'{method} {path} failed: {response.status_code} {response.text[:300]}'
)
if not response.content:
return None
return response.json()
# ── CRUD ────────────────────────────────────────────────────────
def list(self, collection, filter_str=None, sort=None, page=1, per_page=50):
"""
Lists records in a collection, optionally filtered/sorted.
Data flow:
collection + filter/sort/pagination →
GET /api/collections/{collection}/records →
{ items, page, totalItems, ... } → items list returned
"""
params = {'page': page, 'perPage': per_page}
if filter_str:
params['filter'] = filter_str
if sort:
params['sort'] = sort
data = self._request('GET', f'/api/collections/{collection}/records', params=params)
return data.get('items', []) if data else []
def get_first(self, collection, filter_str):
"""Returns the first record matching filter_str, or None if no match."""
items = self.list(collection, filter_str=filter_str, per_page=1)
return items[0] if items else None
def get_one(self, collection, record_id):
"""Returns a single record by id, or None if it does not exist."""
try:
return self._request('GET', f'/api/collections/{collection}/records/{record_id}')
except PocketBaseError as e:
if '404' in str(e):
return None
raise
def create(self, collection, data):
"""Creates a record and returns the created record (including its id)."""
return self._request('POST', f'/api/collections/{collection}/records', json=data)
def update(self, collection, record_id, data):
"""Patches a record and returns the updated record."""
return self._request('PATCH', f'/api/collections/{collection}/records/{record_id}', json=data)
def delete(self, collection, record_id):
"""Deletes a record. Returns True on success."""
self._request('DELETE', f'/api/collections/{collection}/records/{record_id}')
return True
def auth_refresh(self, user_token):
"""
Validates a frontend-issued PocketBase user JWT and returns the
associated auth record (email, id, etc.) — used by auth_service
to resolve `tenant_id` from a request's Authorization header
without the backend needing its own JWT-verification secret.
Data flow:
user_token (Bearer JWT from the Vue dashboard) →
POST /api/collections/users/auth-refresh →
{ record: { email, id, ... } } → record dict returned,
or None if the token is invalid/expired
"""
try:
response = requests.post(
f'{POCKETBASE_URL}/api/collections/users/auth-refresh',
headers={'Authorization': f'Bearer {user_token}'},
timeout=10
)
except requests.RequestException as e:
logger.error(f'PocketBase auth-refresh request failed: {str(e)}')
return None
if response.status_code != 200:
return None
return response.json().get('record')
# Module-level singleton — every repository imports this same instance so
# the auth token is cached once per process, not once per repository.
pb_client = PocketBaseClient()