DealDocumentScreening/src/contract_check/api/routes/b2b.py

292 lines
9.7 KiB
Python

"""B2B API routes: external clients analyze contracts via `X-API-Key`.
Management endpoints (create/revoke/list keys) use service-token auth because they
are called by internal adapters (web/cli) on behalf of a user identified by
`telegram_id`. Analysis endpoints use `X-API-Key` auth only.
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from src.contract_check.api.deps import (
ApiKeyAuthDep,
AsyncSessionDep,
CurrentUserDep,
PublisherDep,
StorageDep,
fetch_document_status_for_user,
)
from src.contract_check.api.schemas import (
ApiKeyCreatedResponse,
ApiKeyResponse,
ApiKeyUsageDetailResponse,
B2BUsageResponse,
CreateApiKeyRequest,
DocumentUploadResponse,
ReportInProgressResponse,
ReportResponse,
RevokeApiKeyResponse,
UserProfile,
)
from src.contract_check.api.services import upload_and_enqueue
from src.contract_check.core.api_keys import generate_api_key, hash_api_key
from src.contract_check.core.db.repositories import ApiKeyRepository, UserProfileRepository
from src.contract_check.core.logging import get_logger
log = get_logger(__name__)
router = APIRouter(tags=["b2b"])
@router.post(
"/api/v1/analyze", response_model=DocumentUploadResponse, status_code=status.HTTP_202_ACCEPTED
)
async def analyze_document(
auth: ApiKeyAuthDep,
session: AsyncSessionDep,
storage: StorageDep,
publisher: PublisherDep,
file: Annotated[UploadFile, File()],
) -> DocumentUploadResponse:
"""Upload a document for analysis using a B2B API key.
Reserves one credit from the key owner's account, stores the file, enqueues
the extraction job, and returns 202 + document_id/correlation_id for polling.
"""
result = await upload_and_enqueue(session, storage, publisher, auth.user_id, file)
# Record usage for this key.
try:
key_repo = ApiKeyRepository(session)
await key_repo.record_request(auth.api_key_id, uuid.UUID(str(result.document_id)))
await key_repo.bump_monthly_used(auth.api_key_id)
await session.commit()
except Exception as exc:
log.error(
"api_key_request_log_failed",
api_key_id=str(auth.api_key_id),
document_id=result.document_id,
error=str(exc),
)
# Usage logging is best-effort; do not fail the upload.
return result
@router.get(
"/api/v1/b2b/reports/{document_id}", response_model=ReportResponse | ReportInProgressResponse
)
async def get_b2b_report(
auth: ApiKeyAuthDep,
session: AsyncSessionDep,
document_id: uuid.UUID,
) -> ReportResponse | ReportInProgressResponse:
"""Poll for an analysis report scoped to the API key owner."""
row = await fetch_document_status_for_user(session, document_id, auth.user_id)
if row is None:
raise HTTPException(status_code=404, detail="report not found")
doc_status = row["status"]
if doc_status != "done":
return ReportInProgressResponse(
document_id=str(document_id),
status=doc_status,
stage=row["stage"],
)
return ReportResponse(
document_id=str(document_id),
status=doc_status,
filename=row["filename"],
markdown=row["markdown"],
findings=row["content_json"],
model_used=row["model_used"],
prompt_tokens=row["prompt_tokens"],
eval_tokens=row["eval_tokens"],
latency_ms=row["latency_ms"],
)
@router.get("/api/v1/b2b/usage", response_model=B2BUsageResponse)
async def get_usage(
auth: ApiKeyAuthDep,
session: AsyncSessionDep,
) -> B2BUsageResponse:
"""Return current-month usage for the authenticating API key."""
repo = ApiKeyRepository(session)
key = await repo.get_by_id(auth.api_key_id)
assert key is not None
_, monthly_used = await repo.get_monthly_quota_state(auth.api_key_id)
return B2BUsageResponse(
api_key_id=str(auth.api_key_id),
rate_limit_rps=auth.rate_limit_rps,
monthly_quota=key.monthly_quota,
monthly_used=monthly_used,
requests_this_month=await repo.count_requests_this_month(auth.api_key_id),
resets_at=key.resets_at.isoformat() if key.resets_at else None,
)
# ── Profile (X-API-Key auth; same fields as web, D4) ─────────────────────────
@router.get("/api/v1/b2b/profile", response_model=UserProfile)
async def get_b2b_profile(
auth: ApiKeyAuthDep,
session: AsyncSessionDep,
) -> UserProfile:
"""Return the API key owner's profile (lazy defaults on first read)."""
row = await UserProfileRepository(session).get(auth.user_id)
await session.commit()
return UserProfile(
language=row.language,
timezone=row.timezone,
notif_prefs=dict(row.notif_prefs), # type: ignore[arg-type]
dashboard_prefs=dict(row.dashboard_prefs), # type: ignore[arg-type]
)
@router.put("/api/v1/b2b/profile", response_model=UserProfile)
async def put_b2b_profile(
auth: ApiKeyAuthDep,
session: AsyncSessionDep,
body: UserProfile,
) -> UserProfile:
"""Full-replace the key owner's profile (idempotent PUT — B2B convention).
Intentionally PUT (not PATCH): partners prefer idempotent writes. Billing
fields are never exposed to partners (D4).
"""
repo = UserProfileRepository(session)
await repo.get(auth.user_id) # materialize if absent
row = await repo.update(
auth.user_id,
language=body.language,
timezone=body.timezone,
notif_prefs=body.notif_prefs.model_dump(),
dashboard_prefs=body.dashboard_prefs.model_dump(),
)
await session.commit()
return UserProfile(
language=row.language,
timezone=row.timezone,
notif_prefs=dict(row.notif_prefs), # type: ignore[arg-type]
dashboard_prefs=dict(row.dashboard_prefs), # type: ignore[arg-type]
)
# ── Key management (service-token auth + telegram_id) ────────────────────────
@router.post(
"/api/v1/b2b/keys", response_model=ApiKeyCreatedResponse, status_code=status.HTTP_201_CREATED
)
async def create_api_key(
session: AsyncSessionDep,
user: CurrentUserDep,
body: CreateApiKeyRequest,
) -> ApiKeyCreatedResponse:
"""Create a new B2B API key for the authenticated user.
The raw key is returned **only once**; afterwards only its hash is stored.
"""
rate_limit = body.rate_limit_rps or 3
if rate_limit <= 0:
raise HTTPException(status_code=400, detail="rate_limit_rps must be positive")
raw_key = generate_api_key()
key_hash = hash_api_key(raw_key)
try:
key = await ApiKeyRepository(session).create(
user_id=user.user_id,
name=body.name,
key_hash=key_hash,
rate_limit_rps=rate_limit,
monthly_quota=body.monthly_quota,
)
await session.commit()
except Exception as exc:
log.error("api_key_create_failed", user_id=str(user.user_id), error=str(exc))
raise HTTPException(status_code=500, detail="failed to create API key") from exc
return ApiKeyCreatedResponse(
api_key=raw_key,
id=key.id,
name=key.name,
rate_limit_rps=key.rate_limit_rps,
monthly_quota=key.monthly_quota,
monthly_used=key.monthly_used,
revoked=key.revoked,
created_at=key.created_at.isoformat() if key.created_at else None,
)
@router.get("/api/v1/b2b/keys", response_model=list[ApiKeyResponse])
async def list_api_keys(
session: AsyncSessionDep,
user: CurrentUserDep,
) -> list[ApiKeyResponse]:
"""List B2B API keys for the authenticated user."""
keys = await ApiKeyRepository(session).list_for_user(user.user_id)
return [
ApiKeyResponse(
id=key.id,
name=key.name,
rate_limit_rps=key.rate_limit_rps,
monthly_quota=key.monthly_quota,
monthly_used=key.monthly_used,
revoked=key.revoked,
created_at=key.created_at.isoformat() if key.created_at else None,
)
for key in keys
]
@router.post("/api/v1/b2b/keys/{key_id}/revoke", response_model=RevokeApiKeyResponse)
async def revoke_api_key(
session: AsyncSessionDep,
user: CurrentUserDep,
key_id: uuid.UUID,
) -> RevokeApiKeyResponse:
"""Revoke a B2B API key. Only keys owned by the user may be revoked."""
key = await ApiKeyRepository(session).get_by_id(key_id)
if key is None or key.user_id != user.user_id or key.revoked:
raise HTTPException(status_code=404, detail="key not found or already revoked")
await ApiKeyRepository(session).revoke(key_id)
await session.commit()
return RevokeApiKeyResponse(id=str(key_id), revoked=True)
@router.get("/api/v1/b2b/keys/{key_id}/usage", response_model=ApiKeyUsageDetailResponse)
async def get_key_usage(
session: AsyncSessionDep,
user: CurrentUserDep,
key_id: uuid.UUID,
) -> ApiKeyUsageDetailResponse:
"""Return per-month usage for a specific API key owned by the user."""
repo = ApiKeyRepository(session)
key = await repo.get_by_id(key_id)
if key is None or key.user_id != user.user_id:
raise HTTPException(status_code=404, detail="key not found")
monthly_result = await repo.get_usage_by_key(key_id)
return ApiKeyUsageDetailResponse(
key_id=str(key_id),
name=key.name,
rate_limit_rps=key.rate_limit_rps,
monthly_quota=key.monthly_quota,
monthly_used=key.monthly_used,
resets_at=key.resets_at.isoformat() if key.resets_at else None,
monthly_requests=[
{"month": req.created_at.isoformat() if req.created_at else None, "requests": 1}
for req in monthly_result
],
)