Files
Random-image-api/backend/app/main.py
T

336 lines
13 KiB
Python

from __future__ import annotations
import asyncio
import json
import time
from fastapi import FastAPI, Request
from fastapi.responses import Response
from app.api.admin.router import router as admin_router
from app.api.metrics import router as metrics_router
from app.api.public.healthz import router as healthz_router
from app.api.public.docs_page import router as docs_page_router
from app.api.public.status_page import router as status_page_router
from app.api.public.wtf_page import router as wtf_page_router
from app.api.public.authors import router as authors_router
from app.api.public.images import router as images_router
from app.api.public.legacy import router as legacy_router
from app.api.public.random import router as random_router
from app.api.public.tags import router as tags_router
from app.api.public.version import router as version_router
from app.core.config import load_settings
from app.core.api_keys import ApiKeyAuthConfig, ApiKeyAuthenticator, ApiKeyRateLimiter, require_public_api_key
from app.core.errors import ApiError, ErrorCode, json_error_response
from app.core.logging import configure_logging, get_logger
from app.core.metrics import observe_random_result
from app.core.random_request_persistence import load_persisted_random_totals, persist_random_totals
from app.core.random_request_stats import RandomRequestStats
from app.core.request_id import build_request_id_middleware, get_or_create_request_id, set_request_id_on_state
from app.core.security import decode_jwt, parse_bearer_token
from app.db.engine import create_engine
from app.db.models.admin_audit import AdminAudit
from app.db.session import create_sessionmaker, with_sqlite_busy_retry
from app.web.admin_ui import mount_admin_ui
log = get_logger(__name__)
def create_app() -> FastAPI:
configure_logging()
settings = load_settings()
# NOTE: We reserve `/docs` for a public human-readable documentation page.
# Keep Swagger/Redoc available under `/api/*` paths for troubleshooting.
app = FastAPI(title="new-pixiv-api", docs_url="/api/docs", redoc_url="/api/redoc")
@app.exception_handler(ApiError)
async def _api_error_handler(request: Request, exc: ApiError): # type: ignore[no-redef]
return json_error_response(
code=exc.code,
message=exc.message,
status_code=exc.status_code,
request=request,
details=exc.details,
)
@app.exception_handler(Exception)
async def _unhandled_exception_handler(request: Request, exc: Exception): # type: ignore[no-redef]
try:
log.exception("unhandled_exception path=%s", str(getattr(request, "url", "")))
except Exception:
pass
return json_error_response(
code=ErrorCode.INTERNAL_ERROR,
message="服务器内部错误",
status_code=500,
request=request,
details={"error_type": type(exc).__name__},
)
request_id_middleware = build_request_id_middleware()
if request_id_middleware is not None:
app.add_middleware(request_id_middleware)
engine = create_engine(settings.database_url)
app.state.engine = engine
api_key_cfg = ApiKeyAuthConfig(
required=bool(settings.public_api_key_required),
rpm=int(settings.public_api_key_rpm),
burst=int(settings.public_api_key_burst),
secret_key=str(settings.secret_key),
)
app.state.api_key_authenticator = ApiKeyAuthenticator(engine, api_key_cfg)
app.state.api_key_limiter = ApiKeyRateLimiter(rpm=int(api_key_cfg.rpm), burst=int(api_key_cfg.burst))
app.state.random_request_stats = RandomRequestStats(window_seconds=60)
@app.on_event("startup")
async def _startup() -> None: # type: ignore[no-redef]
engine = getattr(app.state, "engine", None)
stats = getattr(app.state, "random_request_stats", None)
if engine is None or stats is None:
return
try:
totals = await load_persisted_random_totals(engine)
await stats.set_totals(
total_requests=int(totals.get("total_requests", 0) or 0),
total_ok=int(totals.get("total_ok", 0) or 0),
total_error=int(totals.get("total_error", 0) or 0),
)
except Exception:
pass
try:
interval_s = float((settings.random_totals_persist_interval_seconds or 0) or 15)
except Exception:
interval_s = 15.0
interval_s = max(2.0, min(float(interval_s), 300.0))
async def _loop() -> None:
while True:
await asyncio.sleep(float(interval_s))
try:
snap = await stats.snapshot()
await persist_random_totals(
engine,
total_requests=int(snap.total_requests),
total_ok=int(snap.total_ok),
total_error=int(snap.total_error),
source="api",
)
except asyncio.CancelledError:
raise
except Exception:
continue
app.state.random_totals_persist_task = asyncio.create_task(_loop())
@app.middleware("http")
async def _public_api_key_middleware(request: Request, call_next): # type: ignore[no-redef]
if not bool(settings.public_api_key_required):
return await call_next(request)
path = request.url.path
if path.startswith("/admin") or path.startswith("/metrics"):
return await call_next(request)
if path in {"/healthz", "/version", "/openapi.json", "/docs", "/status", "/status.json", "/wtf", "/api/docs", "/api/redoc"}:
return await call_next(request)
rid = get_or_create_request_id(request)
set_request_id_on_state(request, rid)
try:
api_key_id = await require_public_api_key(
request.app.state.api_key_authenticator,
request.app.state.api_key_limiter,
headers=request.headers,
)
except ApiError as exc:
return json_error_response(
code=exc.code,
message=exc.message,
status_code=exc.status_code,
request=request,
details=exc.details,
)
request.state.api_key_id = int(api_key_id)
return await call_next(request)
def _best_effort_admin_actor(request: Request) -> str | None:
authorization = request.headers.get("Authorization") or request.headers.get("authorization")
token = parse_bearer_token(authorization)
if not token:
return None
try:
claims = decode_jwt(token, secret_key=str(settings.secret_key))
except Exception:
return None
sub = str(claims.get("sub") or "").strip()
return sub or None
def _best_effort_client_ip(request: Request) -> str | None:
xff = request.headers.get("X-Forwarded-For") or request.headers.get("x-forwarded-for")
if xff:
ip = str(xff).split(",", 1)[0].strip()
return ip or None
client = getattr(request, "client", None)
host = getattr(client, "host", None) if client is not None else None
return str(host).strip() if host else None
@app.middleware("http")
async def _admin_audit_middleware(request: Request, call_next): # type: ignore[no-redef]
path = request.url.path
method = (request.method or "").upper()
response = await call_next(request)
if not path.startswith("/admin/api/"):
return response
if method in {"GET", "HEAD", "OPTIONS"}:
return response
try:
status_code = int(getattr(response, "status_code", 0) or 0)
except Exception:
status_code = 0
if status_code >= 400:
return response
try:
rid = get_or_create_request_id(request)
actor = _best_effort_admin_actor(request)
ip = _best_effort_client_ip(request)
user_agent = request.headers.get("User-Agent") or request.headers.get("user-agent")
segments = [seg for seg in path.split("/") if seg]
record_id = next((seg for seg in reversed(segments) if seg.isdigit()), None)
detail_json = {"status": status_code, "query": dict(request.query_params)}
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> None:
async with Session() as session:
session.add(
AdminAudit(
actor=actor,
action=method,
resource=path,
record_id=str(record_id) if record_id else None,
request_id=str(rid),
ip=ip,
user_agent=str(user_agent) if user_agent else None,
detail_json=json.dumps(detail_json, ensure_ascii=False, separators=(",", ":")),
)
)
await session.commit()
await with_sqlite_busy_retry(_op)
except Exception:
pass
return response
def _random_result_from_status(status: int) -> str:
if status in {200, 301, 302, 303, 307, 308}:
return "ok"
if status == 404:
return "no_match"
if status == 502:
return "upstream_error"
if status == 400:
return "bad_request"
return "error"
@app.middleware("http")
async def _metrics_middleware(request: Request, call_next): # type: ignore[no-redef]
if request.url.path != "/random":
return await call_next(request)
started = time.monotonic()
stats = getattr(request.app.state, "random_request_stats", None)
if stats is not None:
try:
await stats.on_begin()
except Exception:
pass
response = None
try:
response = await call_next(request)
return response
finally:
duration_s = time.monotonic() - started
status_code = int(getattr(response, "status_code", 0) or 0)
observe_random_result(result=_random_result_from_status(status_code), duration_s=duration_s)
if stats is not None:
try:
await stats.on_end(status_code=int(status_code))
except Exception:
pass
app.state.settings = settings
@app.on_event("shutdown")
async def _shutdown() -> None: # type: ignore[no-redef]
engine = getattr(app.state, "engine", None)
stats = getattr(app.state, "random_request_stats", None)
if engine is not None and stats is not None:
try:
snap = await asyncio.wait_for(stats.snapshot(), timeout=1.0)
await asyncio.wait_for(
persist_random_totals(
engine,
total_requests=int(snap.total_requests),
total_ok=int(snap.total_ok),
total_error=int(snap.total_error),
source="shutdown",
),
timeout=2.0,
)
except Exception:
pass
task = getattr(app.state, "random_totals_persist_task", None)
if task is not None:
try:
task.cancel()
except Exception:
pass
try:
await asyncio.wait_for(task, timeout=2.0)
except asyncio.CancelledError:
pass
except Exception:
pass
if engine is not None:
await engine.dispose()
@app.get("/favicon.ico", include_in_schema=False)
async def _favicon() -> Response: # type: ignore[no-redef]
return Response(status_code=204, headers={"Cache-Control": "public, max-age=86400"})
app.include_router(healthz_router)
app.include_router(docs_page_router)
app.include_router(status_page_router)
app.include_router(wtf_page_router)
app.include_router(authors_router)
app.include_router(images_router)
app.include_router(legacy_router)
app.include_router(random_router)
app.include_router(tags_router)
app.include_router(version_router)
app.include_router(metrics_router)
app.include_router(admin_router)
mount_admin_ui(app)
return app
app = create_app()