Files
MoviePilot/app/factory.py
T

403 lines
13 KiB
Python

import json
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
from app.api.response import ResponseAPIRoute
from app.adapters.web.correlation import CorrelationIdMiddleware
from app.adapters.web.metrics import HttpMetricsMiddleware
from app.adapters.observability.otel import build_observation_port
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
from app.adapters.web.health import install_health_routes
from app.application.plugin.routes import configure_plugin_routes
from app.application.plugin.runtime import get_plugin_manager
from app.schemas.exception import (
PersistenceUnavailableError,
)
from app.adapters.web.security.access import (
configure_token_codec,
verify_apikey,
verify_token,
)
from app.application.security.token import create_access_token, decode_access_token
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.runtime.correlation import get_correlation_id
from app.runtime.localization import LocaleHelper
from app.runtime.log import configure_correlation_id_provider, logger
from app.runtime.observability import configure_observation
from app.schemas.openai import (
AnthropicErrorDetail,
AnthropicErrorResponse,
OpenAIErrorDetail,
OpenAIErrorResponse,
)
from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail
from app.schemas.response import Response as ApiResponse, ValidationIssue
from app.startup.lifecycle import lifespan
from version import APP_VERSION
def _get_http_exception_message(detail: Any) -> str:
"""将 HTTPException 的 detail 转换为统一消息文本。"""
if isinstance(detail, str) and detail:
return detail
if detail is None:
return "请求失败"
try:
return json.dumps(detail, ensure_ascii=False)
except TypeError:
return str(detail)
def _localize_exception_message(request: Request, message: str) -> str:
"""直接按异常所属请求的语言翻译消息,避免中间件上下文已被恢复。"""
return LocaleHelper.translate_text(
message,
locale=LocaleHelper.get_locale_from_request(request),
)
def _is_mcp_jsonrpc_request(request: Request) -> bool:
"""判断请求是否指向保持原生响应的 MCP JSON-RPC 根端点。"""
request_path = getattr(getattr(request, "url", None), "path", "")
return request_path.rstrip("/") == f"{settings.API_V1_STR}/mcp"
def _get_native_ai_protocol(request: Request) -> str | None:
"""识别需要保持原生错误体的 OpenAI 或 Anthropic 兼容请求。"""
request_path = getattr(getattr(request, "url", None), "path", "")
if request_path.startswith(f"{settings.API_V1_STR}/openai/v1/"):
return "openai"
if request_path.startswith(f"{settings.API_V1_STR}/anthropic/v1/"):
return "anthropic"
return None
def _native_ai_error_response(
protocol: str,
status_code: int,
message: str,
headers: dict[str, str] | None = None,
) -> JSONResponse:
"""按 OpenAI 或 Anthropic 兼容协议构造原生错误响应。"""
if protocol == "openai":
error_type = (
"authentication_error"
if status_code in {401, 403}
else "server_error"
if status_code >= 500
else "invalid_request_error"
)
return JSONResponse(
status_code=status_code,
content=OpenAIErrorResponse(
error=OpenAIErrorDetail(
message=message,
type=error_type,
code=error_type,
)
).model_dump(mode="json"),
headers=headers,
)
error_type = (
"authentication_error"
if status_code in {401, 403}
else "api_error"
if status_code >= 500
else "invalid_request_error"
)
return JSONResponse(
status_code=status_code,
content=AnthropicErrorResponse(
error=AnthropicErrorDetail(type=error_type, message=message)
).model_dump(mode="json"),
headers=headers,
)
def _mcp_jsonrpc_error_response(
status_code: int,
code: int,
message: str,
headers: dict[str, str] | None = None,
) -> JSONResponse:
"""构造带 HTTP 状态码的 MCP JSON-RPC 原生错误响应。"""
return JSONResponse(
status_code=status_code,
content=McpJsonRpcError(
jsonrpc="2.0",
id=None,
error=McpJsonRpcErrorDetail(code=code, message=message),
).model_dump(mode="json"),
headers=headers,
)
def _protocol_validation_error_response(
request: Request,
exc: RequestValidationError,
) -> JSONResponse | None:
"""为 OpenAI 与 Anthropic 兼容端点生成协议原生的参数错误响应。"""
errors = exc.errors()
first_error = errors[0] if errors else {}
location = ".".join(
str(item)
for item in first_error.get("loc", ())
if item not in {"body"}
)
message = str(first_error.get("msg") or "Invalid request parameters.")
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol == "openai":
return JSONResponse(
status_code=422,
content=OpenAIErrorResponse(
error=OpenAIErrorDetail(
message=message,
type="invalid_request_error",
param=location or None,
code="invalid_request_error",
)
).model_dump(mode="json"),
)
if native_ai_protocol == "anthropic":
if location:
message = f"{location}: {message}"
return JSONResponse(
status_code=422,
content=AnthropicErrorResponse(
error=AnthropicErrorDetail(
type="invalid_request_error",
message=message,
)
).model_dump(mode="json"),
)
if _is_mcp_jsonrpc_request(request):
if location:
message = f"{location}: {message}"
return _mcp_jsonrpc_error_response(
status_code=422,
code=-32602,
message=message,
)
return None
async def localized_http_exception_handler(
request: Request,
exc: HTTPException,
) -> JSONResponse:
"""
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
:param request: 当前 HTTP 请求
:param exc: FastAPI HTTP 异常
:return: 统一 JSON 错误响应
"""
message = _localize_exception_message(
request,
_get_http_exception_message(exc.detail),
)
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol:
return _native_ai_error_response(
protocol=native_ai_protocol,
status_code=exc.status_code,
message=message,
headers=exc.headers,
)
if _is_mcp_jsonrpc_request(request):
error_codes = {
400: -32600,
401: -32001,
403: -32001,
404: -32601,
409: -32009,
}
return _mcp_jsonrpc_error_response(
status_code=exc.status_code,
code=error_codes.get(exc.status_code, -32000),
message=message,
headers=exc.headers,
)
return JSONResponse(
status_code=exc.status_code,
content=ApiResponse[None](success=False, message=message).model_dump(mode="json"),
headers=exc.headers,
)
async def persistence_unavailable_handler(
request: Request,
_exc: PersistenceUnavailableError,
) -> JSONResponse:
"""将持久化能力暂不可用映射为可重试的 503 响应。"""
return await localized_http_exception_handler(
request,
HTTPException(
status_code=503,
detail="服务当前繁忙,请稍后重试",
headers={"Retry-After": "1"},
),
)
async def localized_validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""
将请求参数校验错误转换为统一响应并保留结构化错误数据。
:param request: 当前 HTTP 请求
:param exc: FastAPI 请求参数校验异常
:return: 统一 JSON 错误响应
"""
protocol_response = _protocol_validation_error_response(request, exc)
if protocol_response is not None:
return protocol_response
errors = [
ValidationIssue(
location=list(error.get("loc", ())),
message=str(error.get("msg") or "请求参数错误"),
error_type=str(error.get("type") or "validation_error"),
)
for error in exc.errors()
]
return JSONResponse(
status_code=422,
content=ApiResponse[list[ValidationIssue]](
success=False,
message=_localize_exception_message(request, "请求参数不正确"),
data=errors,
).model_dump(mode="json"),
)
async def localized_unhandled_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
"""
将未捕获异常隐藏为统一的服务器错误响应,避免泄露内部细节。
:param request: 当前 HTTP 请求
:param exc: 未捕获异常
:return: 统一 JSON 错误响应
"""
logger.error(
f"API 请求发生未捕获异常: {exc}",
exc_info=(type(exc), exc, exc.__traceback__),
)
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol:
return _native_ai_error_response(
protocol=native_ai_protocol,
status_code=500,
message="Internal server error.",
)
if _is_mcp_jsonrpc_request(request):
return _mcp_jsonrpc_error_response(
status_code=500,
code=-32603,
message="Internal error",
)
return JSONResponse(
status_code=500,
content=ApiResponse[None](
success=False,
message=_localize_exception_message(request, "未知错误"),
).model_dump(mode="json"),
)
def create_app() -> FastAPI:
"""
创建并配置 FastAPI 应用实例。
"""
configure_correlation_id_provider(get_correlation_id)
configure_observation(build_observation_port())
_app = FastAPI(
title=settings.PROJECT_NAME,
version=APP_VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
_app.add_exception_handler(
PersistenceUnavailableError,
persistence_unavailable_handler,
)
_app.add_exception_handler(
RequestValidationError,
localized_validation_exception_handler,
)
_app.add_exception_handler(Exception, localized_unhandled_exception_handler)
# 主程序静态路由统一使用 ResponseAPIRoute;动态插件注册时会显式覆盖为原生 APIRoute。
_app.router.route_class = ResponseAPIRoute
# 编排器探针使用原生 APIRoute 和最小响应,不进入业务响应包络或版本前缀。
install_health_routes(_app)
# 配置 CORS 中间件
_app.add_middleware(
CORSMiddleware, # noqa
allow_origins=settings.ALLOWED_HOSTS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
_app.add_middleware(CorrelationIdMiddleware)
_app.add_middleware(HttpMetricsMiddleware)
@_app.middleware("http")
async def locale_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""
为每个请求设置后端多语言上下文。
"""
token = LocaleHelper.set_current_locale(
LocaleHelper.get_locale_from_request(request)
)
try:
return await call_next(request)
finally:
LocaleHelper.reset_current_locale(token)
# HTTP 适配器只持有令牌编解码端口,具体实现由组合根在创建应用时连接。
configure_token_codec(create_access_token, decode_access_token)
# 向 application 层插件路由服务注入应用实例,插件 API 的动态注册/移除
# 统一经服务完成,避免 api.endpoints 反向依赖本模块。
configure_plugin_routes(FastAPIDynamicRouteRegistry(
app=_app,
plugin_ids=lambda: get_plugin_manager().get_running_plugin_ids(),
plugin_apis=lambda plugin_id: get_plugin_manager().get_plugin_apis(plugin_id),
verify_token=verify_token,
verify_apikey=verify_apikey,
prefix=f"{settings.API_V1_STR}/plugin",
protected_routes={
f"{settings.API_V1_STR}/openapi.json",
"/docs",
"/docs/oauth2-redirect",
"/redoc",
},
log=logger,
))
return _app
# 创建 FastAPI 应用实例;所有组合根装配副作用都在 create_app() 内部完成
app = create_app()