659 lines
20 KiB
Python
659 lines
20 KiB
Python
"""
|
|
Developer API Management - Admin endpoints for API Keys, OpenAPI Clients,
|
|
API Call Statistics, and Slow Request Logs.
|
|
"""
|
|
import secrets
|
|
import hashlib
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, desc, asc
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...database import get_db
|
|
from ...models import (
|
|
ApiKey, OpenApiClient, ApiCallStat, SlowRequestLog, User
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
CST_TZ = timezone(timedelta(hours=8))
|
|
|
|
|
|
def cst_now():
|
|
return datetime.now(CST_TZ)
|
|
|
|
|
|
def fmt_time(dt):
|
|
if dt is None:
|
|
return None
|
|
if dt.tzinfo is None:
|
|
return dt.replace(tzinfo=CST_TZ).isoformat()
|
|
return dt.isoformat()
|
|
|
|
|
|
# ============================================================
|
|
# Pydantic Models
|
|
# ============================================================
|
|
|
|
class ApiKeyCreate(BaseModel):
|
|
user_id: str
|
|
name: str = ""
|
|
description: str = ""
|
|
rate_limit: int = 60
|
|
expired_at: str = None
|
|
|
|
|
|
class ApiKeyUpdate(BaseModel):
|
|
name: str = None
|
|
description: str = None
|
|
rate_limit: int = None
|
|
status: str = None
|
|
expired_at: str = None
|
|
|
|
|
|
class OpenAPIClientCreate(BaseModel):
|
|
user_id: str
|
|
name: str
|
|
allowed_models: list = None
|
|
rate_limit: int = 60
|
|
|
|
|
|
class OpenAPIClientUpdate(BaseModel):
|
|
name: str = None
|
|
allowed_models: list = None
|
|
rate_limit: int = None
|
|
status: str = None
|
|
|
|
|
|
# ============================================================
|
|
# Helper functions
|
|
# ============================================================
|
|
|
|
def _generate_api_key():
|
|
"""Generate a unique API key string."""
|
|
return "sk-" + secrets.token_hex(24)
|
|
|
|
|
|
def _generate_secret():
|
|
"""Generate a secret string."""
|
|
return secrets.token_hex(32)
|
|
|
|
|
|
def _generate_client_id():
|
|
"""Generate a unique client ID."""
|
|
return "cli_" + secrets.token_hex(12)
|
|
|
|
|
|
def _generate_client_secret():
|
|
"""Generate a client secret."""
|
|
return secrets.token_hex(32)
|
|
|
|
|
|
def _serialize_api_key(key, user=None):
|
|
return {
|
|
"id": key.id,
|
|
"user_id": key.user_id,
|
|
"username": user.username if user else None,
|
|
"api_key": key.api_key,
|
|
"name": key.name,
|
|
"description": getattr(key, "description", "") or "",
|
|
"rate_limit": getattr(key, "rate_limit", 60) or 60,
|
|
"total_calls": getattr(key, "total_calls", 0) or 0,
|
|
"status": key.status,
|
|
"last_used_at": fmt_time(key.last_used_at),
|
|
"created_at": fmt_time(key.created_at),
|
|
"expired_at": fmt_time(key.expired_at),
|
|
}
|
|
|
|
|
|
def _serialize_client(client, user=None):
|
|
return {
|
|
"id": client.id,
|
|
"user_id": client.user_id,
|
|
"username": user.username if user else None,
|
|
"name": client.name,
|
|
"client_id": client.client_id,
|
|
"allowed_models": client.allowed_models or [],
|
|
"rate_limit": client.rate_limit,
|
|
"total_calls": getattr(client, "total_calls", 0) or 0,
|
|
"last_used_at": fmt_time(getattr(client, "last_used_at", None)),
|
|
"status": client.status,
|
|
"created_at": fmt_time(client.created_at),
|
|
}
|
|
|
|
|
|
def _serialize_call_stat(stat):
|
|
avg_time = 0.0
|
|
if stat.call_count and stat.call_count > 0:
|
|
avg_time = round(stat.total_response_time / stat.call_count, 3)
|
|
error_rate = 0.0
|
|
if stat.call_count and stat.call_count > 0:
|
|
error_rate = round(stat.error_count / stat.call_count * 100, 2)
|
|
return {
|
|
"id": stat.id,
|
|
"endpoint": stat.endpoint,
|
|
"method": stat.method,
|
|
"call_count": stat.call_count,
|
|
"error_count": stat.error_count,
|
|
"error_rate": error_rate,
|
|
"avg_response_time": avg_time,
|
|
"total_response_time": round(stat.total_response_time, 3),
|
|
"last_called_at": fmt_time(stat.last_called_at),
|
|
"created_at": fmt_time(stat.created_at),
|
|
"updated_at": fmt_time(stat.updated_at),
|
|
}
|
|
|
|
|
|
def _serialize_slow_log(log):
|
|
return {
|
|
"id": log.id,
|
|
"request_id": log.request_id,
|
|
"method": log.method,
|
|
"endpoint": log.endpoint,
|
|
"status_code": log.status_code,
|
|
"response_time": round(log.response_time, 3),
|
|
"threshold": round(log.threshold, 2),
|
|
"created_at": fmt_time(log.created_at),
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Stats Overview
|
|
# ============================================================
|
|
|
|
@router.get("/stats/overview")
|
|
async def get_stats_overview(db: Session = Depends(get_db)):
|
|
"""Get developer API dashboard statistics."""
|
|
now = cst_now()
|
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
today_start_naive = today_start.replace(tzinfo=None)
|
|
|
|
# API Key stats
|
|
total_keys = db.query(func.count(ApiKey.id)).scalar() or 0
|
|
active_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "active").scalar() or 0
|
|
revoked_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "revoked").scalar() or 0
|
|
|
|
# OpenAPI Client stats
|
|
total_clients = db.query(func.count(OpenApiClient.id)).scalar() or 0
|
|
active_clients = db.query(func.count(OpenApiClient.id)).filter(OpenApiClient.status == "active").scalar() or 0
|
|
|
|
# Call stats
|
|
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
|
|
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
|
|
total_response_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
|
|
avg_response_time = round(total_response_time / total_calls, 3) if total_calls > 0 else 0.0
|
|
error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0
|
|
|
|
# Today's calls (approximate by last_called_at)
|
|
today_calls = db.query(func.sum(ApiCallStat.call_count)).filter(
|
|
ApiCallStat.last_called_at >= today_start_naive
|
|
).scalar() or 0
|
|
|
|
# Slow request count
|
|
slow_log_count = db.query(func.count(SlowRequestLog.id)).scalar() or 0
|
|
today_slow = db.query(func.count(SlowRequestLog.id)).filter(
|
|
SlowRequestLog.created_at >= today_start_naive
|
|
).scalar() or 0
|
|
|
|
# Top endpoints by call count
|
|
top_endpoints = db.query(ApiCallStat).order_by(desc(ApiCallStat.call_count)).limit(5).all()
|
|
top_endpoints_data = [
|
|
{
|
|
"endpoint": s.endpoint,
|
|
"method": s.method,
|
|
"call_count": s.call_count,
|
|
"error_count": s.error_count,
|
|
}
|
|
for s in top_endpoints
|
|
]
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"api_keys": {
|
|
"total": total_keys,
|
|
"active": active_keys,
|
|
"revoked": revoked_keys,
|
|
},
|
|
"openapi_clients": {
|
|
"total": total_clients,
|
|
"active": active_clients,
|
|
},
|
|
"call_stats": {
|
|
"total_calls": total_calls,
|
|
"today_calls": today_calls,
|
|
"total_errors": total_errors,
|
|
"error_rate": error_rate,
|
|
"avg_response_time": avg_response_time,
|
|
},
|
|
"slow_logs": {
|
|
"total": slow_log_count,
|
|
"today": today_slow,
|
|
},
|
|
"top_endpoints": top_endpoints_data,
|
|
"server_time": cst_now().isoformat(),
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# API Key Management
|
|
# ============================================================
|
|
|
|
@router.get("/api-keys")
|
|
async def list_api_keys(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
status: str = Query(None),
|
|
keyword: str = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List all API keys with pagination."""
|
|
query = db.query(ApiKey, User).outerjoin(User, ApiKey.user_id == User.id)
|
|
|
|
if status:
|
|
query = query.filter(ApiKey.status == status)
|
|
if keyword:
|
|
query = query.filter(
|
|
(ApiKey.name.ilike(f"%{keyword}%")) |
|
|
(ApiKey.api_key.ilike(f"%{keyword}%")) |
|
|
(User.username.ilike(f"%{keyword}%"))
|
|
)
|
|
|
|
total = query.count()
|
|
items = query.order_by(desc(ApiKey.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": [_serialize_api_key(k, u) for k, u in items],
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/api-keys")
|
|
async def create_api_key(body: ApiKeyCreate, db: Session = Depends(get_db)):
|
|
"""Create a new API key."""
|
|
# Verify user exists
|
|
user = db.query(User).filter(User.id == body.user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Check if model has description/rate_limit/total_calls columns
|
|
new_key = ApiKey(
|
|
user_id=body.user_id,
|
|
api_key=_generate_api_key(),
|
|
secret=_generate_secret(),
|
|
name=body.name,
|
|
status="active",
|
|
created_at=datetime.now(),
|
|
)
|
|
|
|
# Set optional fields if they exist on the model
|
|
try:
|
|
new_key.description = body.description
|
|
except Exception:
|
|
pass
|
|
try:
|
|
new_key.rate_limit = body.rate_limit
|
|
except Exception:
|
|
pass
|
|
try:
|
|
new_key.total_calls = 0
|
|
except Exception:
|
|
pass
|
|
|
|
if body.expired_at:
|
|
try:
|
|
new_key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
|
|
except Exception:
|
|
pass
|
|
|
|
db.add(new_key)
|
|
db.commit()
|
|
db.refresh(new_key)
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": _serialize_api_key(new_key, user),
|
|
"message": "API Key created successfully",
|
|
}
|
|
|
|
|
|
@router.put("/api-keys/{key_id}")
|
|
async def update_api_key(key_id: str, body: ApiKeyUpdate, db: Session = Depends(get_db)):
|
|
"""Update an API key."""
|
|
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
|
if not key:
|
|
raise HTTPException(status_code=404, detail="API Key not found")
|
|
|
|
if body.name is not None:
|
|
key.name = body.name
|
|
if body.status is not None:
|
|
key.status = body.status
|
|
if body.rate_limit is not None:
|
|
try:
|
|
key.rate_limit = body.rate_limit
|
|
except Exception:
|
|
pass
|
|
if body.description is not None:
|
|
try:
|
|
key.description = body.description
|
|
except Exception:
|
|
pass
|
|
if body.expired_at is not None:
|
|
try:
|
|
key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
|
|
except Exception:
|
|
pass
|
|
|
|
db.commit()
|
|
db.refresh(key)
|
|
|
|
user = db.query(User).filter(User.id == key.user_id).first()
|
|
return {"code": 0, "data": _serialize_api_key(key, user), "message": "Updated"}
|
|
|
|
|
|
@router.post("/api-keys/{key_id}/revoke")
|
|
async def revoke_api_key(key_id: str, db: Session = Depends(get_db)):
|
|
"""Revoke an API key."""
|
|
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
|
if not key:
|
|
raise HTTPException(status_code=404, detail="API Key not found")
|
|
|
|
key.status = "revoked"
|
|
db.commit()
|
|
|
|
return {"code": 0, "message": "API Key revoked"}
|
|
|
|
|
|
@router.delete("/api-keys/{key_id}")
|
|
async def delete_api_key(key_id: str, db: Session = Depends(get_db)):
|
|
"""Delete an API key permanently."""
|
|
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
|
if not key:
|
|
raise HTTPException(status_code=404, detail="API Key not found")
|
|
|
|
db.delete(key)
|
|
db.commit()
|
|
|
|
return {"code": 0, "message": "API Key deleted"}
|
|
|
|
|
|
# ============================================================
|
|
# OpenAPI Client Management
|
|
# ============================================================
|
|
|
|
@router.get("/openapi-clients")
|
|
async def list_openapi_clients(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
status: str = Query(None),
|
|
keyword: str = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List all OpenAPI clients."""
|
|
query = db.query(OpenApiClient, User).outerjoin(User, OpenApiClient.user_id == User.id)
|
|
|
|
if status:
|
|
query = query.filter(OpenApiClient.status == status)
|
|
if keyword:
|
|
query = query.filter(
|
|
(OpenApiClient.name.ilike(f"%{keyword}%")) |
|
|
(OpenApiClient.client_id.ilike(f"%{keyword}%")) |
|
|
(User.username.ilike(f"%{keyword}%"))
|
|
)
|
|
|
|
total = query.count()
|
|
items = query.order_by(desc(OpenApiClient.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": [_serialize_client(c, u) for c, u in items],
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/openapi-clients")
|
|
async def create_openapi_client(body: OpenAPIClientCreate, db: Session = Depends(get_db)):
|
|
"""Create a new OpenAPI client."""
|
|
user = db.query(User).filter(User.id == body.user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
new_client = OpenApiClient(
|
|
user_id=body.user_id,
|
|
name=body.name,
|
|
client_id=_generate_client_id(),
|
|
client_secret=_generate_client_secret(),
|
|
allowed_models=body.allowed_models,
|
|
rate_limit=body.rate_limit,
|
|
status="active",
|
|
created_at=datetime.now(),
|
|
)
|
|
|
|
try:
|
|
new_client.total_calls = 0
|
|
except Exception:
|
|
pass
|
|
try:
|
|
new_client.last_used_at = None
|
|
except Exception:
|
|
pass
|
|
|
|
db.add(new_client)
|
|
db.commit()
|
|
db.refresh(new_client)
|
|
|
|
result = _serialize_client(new_client, user)
|
|
result["client_secret"] = new_client.client_secret # Only show on create
|
|
|
|
return {"code": 0, "data": result, "message": "OpenAPI Client created"}
|
|
|
|
|
|
@router.put("/openapi-clients/{client_id}")
|
|
async def update_openapi_client(client_id: str, body: OpenAPIClientUpdate, db: Session = Depends(get_db)):
|
|
"""Update an OpenAPI client."""
|
|
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
|
|
if not client:
|
|
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
|
|
|
|
if body.name is not None:
|
|
client.name = body.name
|
|
if body.allowed_models is not None:
|
|
client.allowed_models = body.allowed_models
|
|
if body.rate_limit is not None:
|
|
client.rate_limit = body.rate_limit
|
|
if body.status is not None:
|
|
client.status = body.status
|
|
|
|
db.commit()
|
|
db.refresh(client)
|
|
|
|
user = db.query(User).filter(User.id == client.user_id).first()
|
|
return {"code": 0, "data": _serialize_client(client, user), "message": "Updated"}
|
|
|
|
|
|
@router.post("/openapi-clients/{client_id}/reset-secret")
|
|
async def reset_client_secret(client_id: str, db: Session = Depends(get_db)):
|
|
"""Reset the client secret."""
|
|
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
|
|
if not client:
|
|
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
|
|
|
|
client.client_secret = _generate_client_secret()
|
|
db.commit()
|
|
db.refresh(client)
|
|
|
|
return {"code": 0, "data": {"client_secret": client.client_secret}, "message": "Secret reset"}
|
|
|
|
|
|
@router.delete("/openapi-clients/{client_id}")
|
|
async def delete_openapi_client(client_id: str, db: Session = Depends(get_db)):
|
|
"""Delete an OpenAPI client."""
|
|
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
|
|
if not client:
|
|
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
|
|
|
|
db.delete(client)
|
|
db.commit()
|
|
|
|
return {"code": 0, "message": "OpenAPI Client deleted"}
|
|
|
|
|
|
# ============================================================
|
|
# API Call Statistics
|
|
# ============================================================
|
|
|
|
@router.get("/call-stats")
|
|
async def list_call_stats(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
sort_by: str = Query("call_count"),
|
|
sort_order: str = Query("desc"),
|
|
keyword: str = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List API call statistics."""
|
|
query = db.query(ApiCallStat)
|
|
|
|
if keyword:
|
|
query = query.filter(ApiCallStat.endpoint.ilike(f"%{keyword}%"))
|
|
|
|
sort_column = getattr(ApiCallStat, sort_by, ApiCallStat.call_count)
|
|
if sort_order == "asc":
|
|
query = query.order_by(asc(sort_column))
|
|
else:
|
|
query = query.order_by(desc(sort_column))
|
|
|
|
total = query.count()
|
|
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": [_serialize_call_stat(s) for s in items],
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/call-stats/overview")
|
|
async def call_stats_overview(db: Session = Depends(get_db)):
|
|
"""Get call stats summary."""
|
|
total_endpoints = db.query(func.count(ApiCallStat.id)).scalar() or 0
|
|
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
|
|
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
|
|
avg_time = 0.0
|
|
if total_calls and total_calls > 0:
|
|
total_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
|
|
avg_time = round(total_time / total_calls, 3)
|
|
|
|
# Method distribution
|
|
method_stats = db.query(
|
|
ApiCallStat.method,
|
|
func.sum(ApiCallStat.call_count).label("count")
|
|
).group_by(ApiCallStat.method).all()
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"total_endpoints": total_endpoints,
|
|
"total_calls": total_calls,
|
|
"total_errors": total_errors,
|
|
"error_rate": round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0,
|
|
"avg_response_time": avg_time,
|
|
"method_distribution": [
|
|
{"method": m, "count": c} for m, c in method_stats
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Slow Request Logs
|
|
# ============================================================
|
|
|
|
@router.get("/slow-logs")
|
|
async def list_slow_logs(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
keyword: str = Query(None),
|
|
min_time: float = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List slow request logs."""
|
|
query = db.query(SlowRequestLog)
|
|
|
|
if keyword:
|
|
query = query.filter(SlowRequestLog.endpoint.ilike(f"%{keyword}%"))
|
|
if min_time is not None:
|
|
query = query.filter(SlowRequestLog.response_time >= min_time)
|
|
|
|
total = query.count()
|
|
items = query.order_by(desc(SlowRequestLog.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": [_serialize_slow_log(l) for l in items],
|
|
}
|
|
}
|
|
|
|
|
|
@router.delete("/slow-logs")
|
|
async def clear_slow_logs(db: Session = Depends(get_db)):
|
|
"""Clear all slow request logs."""
|
|
deleted = db.query(SlowRequestLog).delete()
|
|
db.commit()
|
|
|
|
return {"code": 0, "message": f"Cleared {deleted} slow request logs"}
|
|
|
|
|
|
@router.get("/slow-logs/stats")
|
|
async def slow_logs_stats(db: Session = Depends(get_db)):
|
|
"""Get slow request log statistics."""
|
|
now = cst_now()
|
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
|
|
|
total = db.query(func.count(SlowRequestLog.id)).scalar() or 0
|
|
today = db.query(func.count(SlowRequestLog.id)).filter(
|
|
SlowRequestLog.created_at >= today_start
|
|
).scalar() or 0
|
|
|
|
avg_time = db.query(func.avg(SlowRequestLog.response_time)).scalar() or 0.0
|
|
max_time = db.query(func.max(SlowRequestLog.response_time)).scalar() or 0.0
|
|
|
|
# Distribution by status code
|
|
status_dist = db.query(
|
|
SlowRequestLog.status_code,
|
|
func.count(SlowRequestLog.id).label("count")
|
|
).group_by(SlowRequestLog.status_code).all()
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"total": total,
|
|
"today": today,
|
|
"avg_response_time": round(float(avg_time), 3),
|
|
"max_response_time": round(float(max_time), 3),
|
|
"status_distribution": [
|
|
{"status_code": s, "count": c} for s, c in status_dist
|
|
],
|
|
}
|
|
}
|