Arag / app /services /platform_api_registry.py
AuthorBot
SuperAdmin QoL: persistent session, TOTP session, shared provider keys.
65a6194
Raw
History Blame Contribute Delete
17.5 kB
"""Platform API credential registry — providers, env fallbacks, resolution order."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Literal
import httpx
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.crypto.credential_crypto import decrypt_secret, mask_secret
from app.repositories.platform_api_credential_repo import PlatformApiCredentialRepository
from app.services.platform_pricing import (
PriceContext,
PlatformPrice,
extract_price_from_html,
format_price,
)
logger = structlog.get_logger(__name__)
_CACHE_PREFIX = "platform_api_cred:v1:"
_CACHE_TTL = 300
ProviderId = Literal["google_books", "rainforest", "scrapingbee"]
PROVIDER_SCOPE_PREFIX = "@provider:"
PROVIDER_DEFINITIONS: dict[str, dict] = {
"scrapingbee": {
"display_name": "ScrapingBee",
"description": "Shared proxy for B&N, Apple, Kobo, ThriftBooks, AbeBooks, Bookshop",
},
"rainforest": {
"display_name": "Rainforest API",
"description": "Amazon product data API",
},
"google_books": {
"display_name": "Google Books API",
"description": "Google Books volumes and pricing",
},
}
PLATFORM_API_META: dict[str, dict] = {
"amazon": {
"display_name": "Amazon",
"providers": ["rainforest", "scrapingbee"],
"default_provider": "rainforest",
"env_key": "RAINFOREST_API_KEY",
},
"google_books": {
"display_name": "Google Books",
"providers": ["google_books"],
"default_provider": "google_books",
"env_key": "GOOGLE_BOOKS_API_KEY",
},
"barnes_noble": {
"display_name": "Barnes & Noble",
"providers": ["scrapingbee"],
"default_provider": "scrapingbee",
"env_key": None,
},
"apple_books": {
"display_name": "Apple Books",
"providers": ["scrapingbee"],
"default_provider": "scrapingbee",
"env_key": None,
},
"kobo": {
"display_name": "Kobo",
"providers": ["scrapingbee"],
"default_provider": "scrapingbee",
"env_key": None,
},
"thriftbooks": {
"display_name": "ThriftBooks",
"providers": ["scrapingbee"],
"default_provider": "scrapingbee",
"env_key": None,
},
"abebooks": {
"display_name": "AbeBooks",
"providers": ["scrapingbee"],
"default_provider": "scrapingbee",
"env_key": None,
},
"bookshop": {
"display_name": "Bookshop.org",
"providers": ["scrapingbee"],
"default_provider": "scrapingbee",
"env_key": None,
},
"goodreads": {
"display_name": "Goodreads",
"providers": [],
"default_provider": None,
"env_key": None,
},
"open_library": {
"display_name": "Open Library",
"providers": [],
"default_provider": None,
"env_key": None,
},
}
@dataclass
class EffectiveCredential:
"""Resolved credential ready for outbound API calls."""
platform_id: str
provider: str
api_key: str
api_secret: str | None
source: Literal["superadmin", "environment"]
@dataclass
class PlatformApiCredentialView:
"""Masked credential for SuperAdmin API responses."""
platform_id: str
display_name: str
provider: str | None
api_key_hint: str | None
has_secret: bool
is_enabled: bool
source: str
available_providers: list[str]
def provider_scope_id(provider: str) -> str:
"""Canonical DB key for a shared provider credential."""
return f"{PROVIDER_SCOPE_PREFIX}{provider}"
def is_provider_scope(platform_id: str) -> bool:
"""True when platform_id is a shared provider scope row."""
return platform_id.startswith(PROVIDER_SCOPE_PREFIX)
def provider_id_from_scope(scope_id: str) -> str:
"""Extract provider id from @provider:scrapingbee."""
return scope_id.removeprefix(PROVIDER_SCOPE_PREFIX)
def platforms_for_provider(provider: str) -> list[str]:
"""Retail platform IDs that can use this provider."""
return [
pid for pid, meta in PLATFORM_API_META.items()
if provider in meta.get("providers", [])
]
def _env_credential_for_provider(provider: str) -> EffectiveCredential | None:
"""First matching env fallback for any platform using this provider."""
for pid in platforms_for_provider(provider):
meta = PLATFORM_API_META.get(pid, {})
if meta.get("default_provider") != provider:
continue
cred = _env_credential(pid)
if cred:
return cred
return None
def _env_credential(platform_id: str) -> EffectiveCredential | None:
"""Build credential from environment when configured."""
meta = PLATFORM_API_META.get(platform_id)
if not meta or not meta.get("env_key"):
return None
env_name = meta["env_key"]
cfg = get_settings()
raw = (getattr(cfg, env_name, None) or "").strip()
if not raw:
return None
provider = meta["default_provider"]
if not provider:
return None
return EffectiveCredential(
platform_id=platform_id,
provider=provider,
api_key=raw,
api_secret=None,
source="environment",
)
def _row_to_effective(row) -> EffectiveCredential:
"""Decrypt DB row into runtime credential."""
secret = None
if row.api_secret_encrypted:
secret = decrypt_secret(row.api_secret_encrypted)
return EffectiveCredential(
platform_id=row.platform_id,
provider=row.provider,
api_key=decrypt_secret(row.api_key_encrypted),
api_secret=secret,
source="superadmin",
)
async def _cache_get(redis, platform_id: str) -> EffectiveCredential | None:
if redis is None:
return None
try:
raw = await redis.get(_CACHE_PREFIX + platform_id)
if not raw:
return None
data = json.loads(raw)
if data.get("empty"):
return None
return EffectiveCredential(**data)
except Exception:
return None
async def _cache_set(redis, platform_id: str, cred: EffectiveCredential | None) -> None:
if redis is None:
return
try:
key = _CACHE_PREFIX + platform_id
if cred is None:
await redis.set(key, json.dumps({"empty": True}), ex=_CACHE_TTL)
else:
await redis.set(
key,
json.dumps({
"platform_id": cred.platform_id,
"provider": cred.provider,
"api_key": cred.api_key,
"api_secret": cred.api_secret,
"source": cred.source,
}),
ex=_CACHE_TTL,
)
except Exception:
pass
async def invalidate_credential_cache(redis, platform_id: str | None = None) -> None:
"""Clear cached credentials after SuperAdmin writes (R-179)."""
if redis is None:
return
try:
if platform_id:
await redis.delete(_CACHE_PREFIX + platform_id)
if is_provider_scope(platform_id):
for pid in platforms_for_provider(provider_id_from_scope(platform_id)):
await redis.delete(_CACHE_PREFIX + pid)
else:
keys = await redis.keys(_CACHE_PREFIX + "*")
if keys:
await redis.delete(*keys)
except Exception:
pass
async def resolve_platform_credential(
platform_id: str,
*,
db: AsyncSession | None = None,
redis=None,
) -> EffectiveCredential | None:
"""Resolve credential: platform override → provider scope → environment → None."""
cached = await _cache_get(redis, platform_id)
if cached is not None or (redis and await redis.get(_CACHE_PREFIX + platform_id)):
return cached
cred: EffectiveCredential | None = None
if db is not None:
repo = PlatformApiCredentialRepository(db)
row = await repo.get_enabled(platform_id)
if row and not is_provider_scope(row.platform_id):
cred = _row_to_effective(row)
if cred is None:
meta = PLATFORM_API_META.get(platform_id)
default_provider = meta.get("default_provider") if meta else None
if default_provider:
scope_row = await repo.get_enabled(provider_scope_id(default_provider))
if scope_row:
cred = _row_to_effective(scope_row)
if cred is None:
cred = _env_credential(platform_id)
await _cache_set(redis, platform_id, cred)
return cred
async def load_all_credentials(
db: AsyncSession | None = None,
redis=None,
) -> dict[str, EffectiveCredential]:
"""Load credentials for all scored platforms."""
from app.services.platform_presence import SCORED_PLATFORMS
out: dict[str, EffectiveCredential] = {}
for cfg in SCORED_PLATFORMS:
pid = cfg["platform_id"]
cred = await resolve_platform_credential(pid, db=db, redis=redis)
if cred:
out[pid] = cred
return out
def _extract_asin(url: str) -> str | None:
m = re.search(r"/(?:dp|gp/product|gp/aw/d)/([A-Z0-9]{10})", url, re.I)
return m.group(1).upper() if m else None
async def _fetch_rainforest_price(
client: httpx.AsyncClient,
cred: EffectiveCredential,
listing_url: str,
) -> PlatformPrice | None:
asin = _extract_asin(listing_url)
if not asin:
return None
try:
r = await client.get(
"https://api.rainforestapi.com/request",
params={
"api_key": cred.api_key,
"type": "product",
"amazon_domain": "amazon.com",
"asin": asin,
},
timeout=15.0,
)
if r.status_code != 200:
return None
data = r.json()
winner = data.get("product", {}).get("buybox_winner") or data.get("buybox_winner") or {}
price = winner.get("price") or {}
amount = price.get("value") or price.get("raw")
if amount is None:
return None
amount_f = float(str(amount).replace(",", "").replace("$", ""))
currency = str(price.get("currency") or "USD").upper()
return PlatformPrice(
formatted=format_price(amount_f, currency),
amount=amount_f,
currency=currency,
source="api:rainforest",
)
except Exception as exc:
logger.debug("Rainforest price failed", error=str(exc)[:120])
return None
async def _fetch_scrapingbee_html(
client: httpx.AsyncClient,
cred: EffectiveCredential,
listing_url: str,
) -> str | None:
try:
r = await client.get(
"https://app.scrapingbee.com/api/v1",
params={
"api_key": cred.api_key,
"url": listing_url,
"premium_proxy": "true",
"country_code": "us",
},
timeout=30.0,
)
if r.status_code == 200 and len(r.text) > 500:
return r.text
except Exception as exc:
logger.debug("ScrapingBee fetch failed", error=str(exc)[:120])
return None
async def _fetch_google_books_with_key(
client: httpx.AsyncClient,
cred: EffectiveCredential,
listing_url: str,
ctx: PriceContext | None,
) -> PlatformPrice | None:
from app.services.platform_pricing import fetch_google_books_price
volume_m = re.search(r"[?&]id=([^&]+)", listing_url)
isbn = (ctx.isbn13 if ctx else None) or ""
try:
if volume_m:
r = await client.get(
f"https://www.googleapis.com/books/v1/volumes/{volume_m.group(1)}",
params={"key": cred.api_key, "country": "US"},
timeout=10.0,
)
if r.status_code == 200:
from app.services.platform_pricing import _sale_info_price
hit = _sale_info_price(r.json().get("saleInfo") or {})
if hit:
hit.source = "api:google_books"
return hit
if isbn:
r = await client.get(
"https://www.googleapis.com/books/v1/volumes",
params={
"key": cred.api_key,
"q": f"isbn:{isbn}",
"maxResults": "5",
"country": "US",
},
timeout=10.0,
)
if r.status_code == 200:
from app.services.platform_pricing import _sale_info_price
for item in r.json().get("items", []):
hit = _sale_info_price(item.get("saleInfo") or {})
if hit:
hit.source = "api:google_books"
return hit
except Exception as exc:
logger.debug("Google Books keyed fetch failed", error=str(exc)[:120])
return await fetch_google_books_price(client, listing_url, ctx)
async def fetch_price_via_credential(
client: httpx.AsyncClient,
cred: EffectiveCredential,
platform_id: str,
listing_url: str,
ctx: PriceContext | None = None,
) -> PlatformPrice | None:
"""Fetch price using SuperAdmin/env credential (priority tier 1)."""
if cred.provider == "rainforest":
if platform_id != "amazon":
return None
return await _fetch_rainforest_price(client, cred, listing_url)
if cred.provider == "google_books":
if platform_id != "google_books":
return None
return await _fetch_google_books_with_key(client, cred, listing_url, ctx)
if cred.provider == "scrapingbee":
html = await _fetch_scrapingbee_html(client, cred, listing_url)
if html:
hit = extract_price_from_html(platform_id, html)
if hit:
hit.source = "api:scrapingbee"
return hit
return None
def build_credential_views(rows: list) -> list[PlatformApiCredentialView]:
"""Build SuperAdmin list view for all platforms."""
by_id = {r.platform_id: r for r in rows}
views: list[PlatformApiCredentialView] = []
for pid, meta in PLATFORM_API_META.items():
row = by_id.get(pid)
env_cred = _env_credential(pid)
if row:
key_hint = mask_secret(decrypt_secret(row.api_key_encrypted))
views.append(PlatformApiCredentialView(
platform_id=pid,
display_name=meta["display_name"],
provider=row.provider,
api_key_hint=key_hint,
has_secret=bool(row.api_secret_encrypted),
is_enabled=row.is_enabled,
source="superadmin",
available_providers=list(meta["providers"]),
))
elif env_cred:
views.append(PlatformApiCredentialView(
platform_id=pid,
display_name=meta["display_name"],
provider=env_cred.provider,
api_key_hint=mask_secret(env_cred.api_key),
has_secret=False,
is_enabled=True,
source="environment",
available_providers=list(meta["providers"]),
))
else:
views.append(PlatformApiCredentialView(
platform_id=pid,
display_name=meta["display_name"],
provider=meta.get("default_provider"),
api_key_hint=None,
has_secret=False,
is_enabled=False,
source="none",
available_providers=list(meta["providers"]),
))
return views
def build_provider_views(rows: list) -> dict:
"""Build SuperAdmin provider-scoped credential views (one key per provider)."""
by_id = {r.platform_id: r for r in rows}
providers_out: list[dict] = []
for provider_id, info in PROVIDER_DEFINITIONS.items():
scope_id = provider_scope_id(provider_id)
row = by_id.get(scope_id)
env_cred = _env_credential_for_provider(provider_id)
platform_labels = [
{
"platform_id": pid,
"display_name": PLATFORM_API_META[pid]["display_name"],
}
for pid in platforms_for_provider(provider_id)
]
if row and row.is_enabled:
source = "superadmin"
key_hint = mask_secret(decrypt_secret(row.api_key_encrypted))
is_enabled = True
elif env_cred:
source = "environment"
key_hint = mask_secret(env_cred.api_key)
is_enabled = True
else:
source = "none"
key_hint = None
is_enabled = False
providers_out.append({
"provider_id": provider_id,
"scope_id": scope_id,
"display_name": info["display_name"],
"description": info["description"],
"platforms": platform_labels,
"api_key_hint": key_hint,
"is_enabled": is_enabled,
"source": source,
})
without_api = [
{
"platform_id": pid,
"display_name": meta["display_name"],
}
for pid, meta in PLATFORM_API_META.items()
if not meta.get("providers")
]
return {"providers": providers_out, "platforms_without_api": without_api}