292 lines
9.7 KiB
Python
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
|
|
],
|
|
)
|