"""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}