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