fix(plugin): keep API docs routes across plugin route updates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
InfinityPacer
2026-09-30 20:29:57 +08:00
committed by InfinityPacer
parent 323c457bf9
commit 8d7d4c6121
5 changed files with 25 additions and 41 deletions

View File

@@ -37,7 +37,6 @@ class FastAPIDynamicRouteRegistry:
verify_token: Callable[..., Any],
verify_apikey: Callable[..., Any],
prefix: str,
protected_routes: set[str],
log: Any,
event_loop: Callable[[], asyncio.AbstractEventLoop | None] | None = None,
) -> None:
@@ -48,7 +47,6 @@ class FastAPIDynamicRouteRegistry:
self._verify_token = verify_token
self._verify_apikey = verify_apikey
self._prefix = prefix
self._protected_routes = protected_routes
self._logger = log
self._event_loop = event_loop
@@ -111,11 +109,6 @@ class FastAPIDynamicRouteRegistry:
modified = False
removed = False
existing_paths = {
path: route
for route in self._app.routes
if (path := self._route_path(route)) is not None
}
plugin_ids = [plugin_id] if plugin_id else self._plugin_ids()
for current_id in plugin_ids:
if self.remove(current_id):
@@ -150,9 +143,8 @@ class FastAPIDynamicRouteRegistry:
f"Error adding plugin route {api_path}: {str(error)}"
)
if modified:
self.clean(existing_paths)
# 文档路由由 app.adapters.web.docs 一次性注册,这里只让已缓存的文档按新路由表重建。
self._app.openapi_schema = None
self._app.setup()
if removed:
# 旧路由已不在路由表中,此后不会再有请求把旧端点写回缓存
_clear_fastapi_callable_caches()
@@ -184,15 +176,3 @@ class FastAPIDynamicRouteRegistry:
"""返回公开路由路径,跳过 FastAPI 内部的无路径 include 包装器。"""
path = getattr(route, "path", None)
return path if isinstance(path, str) else None
def clean(self, existing_paths: dict) -> None:
"""清理 FastAPI 重建时可能重复的受保护文档路由。"""
for protected_route in self._protected_routes:
try:
existing_route = existing_paths.get(protected_route)
if existing_route:
self._app.routes.remove(existing_route)
except Exception as error:
self._logger.error(
f"Error removing protected route {protected_route}: {str(error)}"
)

View File

@@ -404,12 +404,6 @@ def create_app() -> FastAPI:
verify_token=verify_token,
verify_apikey=verify_apikey,
prefix=f"{get_runtime_setting('API_V1_STR')}/plugin",
protected_routes={
openapi_path,
"/docs",
"/docs/oauth2-redirect",
"/redoc",
},
log=logger,
event_loop=lambda: main_loop_registry.current,
))

View File

@@ -3,6 +3,7 @@
import pytest
from fastapi.testclient import TestClient
from app.application.plugin import routes as plugin_routes
from app.application.settings.contract import build_setting_specs
from app.factory import create_app
from app.runtime.config import settings
@@ -44,6 +45,29 @@ def test_api_docs_follow_runtime_toggle_without_restart(monkeypatch: pytest.Monk
assert app.openapi_schema is None
def test_api_docs_survive_plugin_route_updates(monkeypatch: pytest.MonkeyPatch) -> None:
"""插件路由增删后文档路由仍在,FastAPI.setup() 不再补注册它们。"""
app = create_app()
registry = plugin_routes._get_route_registry()
# 测试进程没有运行中的主 loop,改为当前线程同步更新路由表。
monkeypatch.setattr(registry, "_event_loop", None)
monkeypatch.setattr(registry, "_plugin_apis", lambda _plugin_id: [{
"path": "/DemoPlugin/health",
"endpoint": lambda: {"ok": True},
"methods": ["GET"],
"allow_anonymous": True,
}])
monkeypatch.setattr(settings, "API_DOCS_ENABLE", True)
client = TestClient(app)
registry.update("DemoPlugin", "add")
assert client.get("/api/v1/plugin/DemoPlugin/health").json() == {"ok": True}
registry.update("DemoPlugin", "remove")
for path in DOC_PATHS:
assert client.get(path).status_code == 200, path
def test_api_docs_pages_keep_reverse_proxy_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
"""文档页引用的 OpenAPI 地址带上 root_path,与 FastAPI 自带文档一致。"""
monkeypatch.setattr(settings, "API_DOCS_ENABLE", True)

View File

@@ -1102,7 +1102,6 @@ def test_plugin_routes_only_register_v1(monkeypatch):
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
))
@@ -1148,7 +1147,6 @@ async def test_plugin_routes_ignore_included_router_wrappers():
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
))
@@ -1188,18 +1186,12 @@ async def test_plugin_route_updates_run_on_application_event_loop() -> None:
main_thread = threading.get_ident()
mutation_threads: list[int] = []
original_add_api_route = app.router.add_api_route
original_setup = app.setup
def record_add_api_route(*args, **kwargs):
mutation_threads.append(threading.get_ident())
return original_add_api_route(*args, **kwargs)
def record_setup() -> None:
mutation_threads.append(threading.get_ident())
original_setup()
app.router.add_api_route = record_add_api_route
app.setup = record_setup
loop = asyncio.get_running_loop()
registry = FastAPIDynamicRouteRegistry(
app=app,
@@ -1213,7 +1205,6 @@ async def test_plugin_route_updates_run_on_application_event_loop() -> None:
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
event_loop=lambda: loop,
)
@@ -1237,7 +1228,6 @@ def test_plugin_route_update_rejects_stopped_application_loop() -> None:
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
event_loop=lambda: None,
)
@@ -1261,7 +1251,6 @@ async def test_plugin_route_update_abandons_late_application_loop_callback() ->
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
event_loop=lambda: loop,
)
@@ -1306,7 +1295,6 @@ async def test_started_plugin_route_update_waits_for_terminal_result() -> None:
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
event_loop=lambda: loop,
)
@@ -1434,7 +1422,6 @@ def build_plugin_api_app(monkeypatch) -> FastAPI:
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
))
plugin_routes.register_plugin_api("DemoPlugin")

View File

@@ -54,7 +54,6 @@ def test_removed_plugin_route_releases_endpoint_from_fastapi_caches() -> None:
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=_SILENT_LOG,
)
registry.update("RouteCacheDemo", "add")