173 lines
6.7 KiB
Python
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()
|