Arag / app /api /public_api.py
AuthorBot
Add enterprise growth APIs, billing, admin UI, and test suite.
12930b5
Raw
History Blame Contribute Delete
4.44 kB
"""Author RAG β€” Public API (Author-Facing REST API).
Allows Enterprise authors to query their chatbot data programmatically.
Used for: custom dashboards, CRM syncs, Zapier integrations.
All endpoints require an API key (not a JWT) β€” keys are
long-lived and scoped per author.
Routes:
GET /api/v1/conversations β†’ list sessions (API key auth)
GET /api/v1/analytics β†’ summary stats (API key auth)
API Key model is stored as a special ClientAccess grant with role="api_key".
"""
import structlog
from datetime import datetime, timezone
from fastapi import APIRouter, Header, HTTPException, Depends, Query
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.models.chat_session import ChatSession, ChatMessage
from app.models.analytics import AnalyticsEvent
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/v1", tags=["Public API v1"])
# ── API Key Auth Dependency ───────────────────────────────────────────────────
async def get_api_key_author(
x_api_key: str = Header(..., alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
):
"""Validate X-API-Key header and return the author user."""
if not x_api_key or len(x_api_key) < 20:
raise HTTPException(401, "Invalid API key")
# API keys are stored as SHA256 hashes in a dedicated table
# For Phase 2: resolve via ClientAccess.notes field lookup
from app.models.client_access import ClientAccess
import hashlib
key_hash = hashlib.sha256(x_api_key.encode()).hexdigest()
result = await db.execute(
select(ClientAccess).where(
ClientAccess.notes == f"api_key:{key_hash}",
ClientAccess.is_revoked == False, # noqa: E712
)
)
access = result.scalar_one_or_none()
if not access:
raise HTTPException(401, "Invalid or revoked API key")
from app.models.user import User
user = await db.get(User, access.author_id)
if not user or not user.is_active:
raise HTTPException(401, "Author account not found")
# Check API access feature flag on plan
from app.models.pricing_plan import PricingPlan
plan = await db.get(PricingPlan, user.stripe_plan_id or "")
if not plan or not plan.has_api_access:
raise HTTPException(403, "API access requires Enterprise plan")
return user
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/conversations")
async def api_list_conversations(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
author=Depends(get_api_key_author),
db: AsyncSession = Depends(get_db),
):
"""List recent conversations. Max 500 per request."""
result = await db.execute(
select(ChatSession)
.where(ChatSession.author_id == author.id)
.order_by(desc(ChatSession.created_at))
.offset(offset)
.limit(limit)
)
sessions = result.scalars().all()
return {
"data": [
{
"session_id": s.session_id,
"started_at": s.created_at.isoformat() if s.created_at else None,
"book_id": getattr(s, "book_id", None),
}
for s in sessions
],
"limit": limit,
"offset": offset,
}
@router.get("/analytics")
async def api_analytics(
author=Depends(get_api_key_author),
db: AsyncSession = Depends(get_db),
):
"""Return 30-day analytics summary."""
from datetime import timedelta
since = datetime.now(timezone.utc) - timedelta(days=30)
sessions_r = await db.execute(
select(func.count(ChatSession.session_id))
.where(ChatSession.author_id == author.id, ChatSession.created_at >= since)
)
messages_r = await db.execute(
select(func.count(ChatMessage.id))
.join(ChatSession, ChatMessage.session_id == ChatSession.session_id)
.where(ChatSession.author_id == author.id, ChatSession.created_at >= since)
)
return {
"period": "last_30_days",
"total_sessions": sessions_r.scalar() or 0,
"total_messages": messages_r.scalar() or 0,
}