deer-flow/backend/tests/test_extension_gateway_wiring.py
Xuehao Xu f0cb67b223
feat(extensions): expose incremental run evidence reader (#5405)
* feat(extensions): expose incremental run evidence reader

* fix(extensions): address run evidence review feedback

* docs(extensions): clarify run deletion reconciliation

* docs(migrations): align current head documentation

* fix(extensions): isolate run evidence event reads

* test: avoid pinning run change migration to latest head

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-16 21:18:05 +08:00

993 lines
32 KiB
Python

"""Gateway binding tests for app-scoped extension contributions."""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
from deerflow.extensions.registry import ExtensionRegistry
class _Service:
def __init__(
self,
name: str,
events: list[str],
*,
fail_start: bool = False,
fail_stop: bool = False,
) -> None:
self.name = name
self.events = events
self.fail_start = fail_start
self.fail_stop = fail_stop
self.deps = None
async def start(self, deps) -> None:
self.events.append(f"start:{self.name}")
self.deps = deps
if self.fail_start:
raise RuntimeError(f"{self.name} failed")
async def stop(self) -> None:
self.events.append(f"stop:{self.name}")
if self.fail_stop:
raise RuntimeError(f"{self.name} failed")
@pytest.mark.asyncio
async def test_services_start_in_order_with_narrow_deps_and_fail_open():
from deerflow.extensions.gateway import start_services
events: list[str] = []
first = _Service("first", events, fail_start=True)
second = _Service("second", events)
registry = ExtensionRegistry()
with registry.attributed_to("first:install"):
registry.service(first)
with registry.attributed_to("second:install"):
registry.service(second)
extensions = registry.build()
session_factory = object()
config = SimpleNamespace(
token_budget=SimpleNamespace(enabled=False, max_tokens=999),
subagents=SimpleNamespace(max_total_per_run=4),
)
run_evidence_reader = object()
diagnostics = await start_services(
extensions,
config,
session_factory,
run_evidence_reader=run_evidence_reader,
)
assert events == ["start:first", "start:second"]
assert first.deps is second.deps
assert second.deps.app_store is extensions.app_store
assert second.deps.session_factory is session_factory
assert second.deps.run_evidence_reader is run_evidence_reader
assert second.deps.policy.token_budget_enabled is False
assert second.deps.policy.max_total_tokens is None
assert second.deps.policy.max_subagents_per_run == 4
assert [(diagnostic.source, diagnostic.level) for diagnostic in diagnostics] == [("first:install", "error")]
@pytest.mark.asyncio
async def test_service_originated_cancelled_error_does_not_abort_start_batch():
from deerflow.extensions.gateway import start_services
events: list[str] = []
class _CancelsItself(_Service):
async def start(self, deps) -> None:
self.events.append(f"start:{self.name}")
raise asyncio.CancelledError()
first = _CancelsItself("first", events)
second = _Service("second", events)
registry = ExtensionRegistry()
with registry.attributed_to("first:install"):
registry.service(first)
with registry.attributed_to("second:install"):
registry.service(second)
diagnostics = await start_services(registry.build(), SimpleNamespace(), None)
assert events == ["start:first", "start:second"]
assert len(diagnostics) == 1
assert diagnostics[0].source == "first:install"
assert "CancelledError" in diagnostics[0].message
@pytest.mark.asyncio
async def test_services_stop_in_reverse_order_and_fail_open():
from deerflow.extensions.gateway import stop_services
events: list[str] = []
first = _Service("first", events)
second = _Service("second", events, fail_stop=True)
registry = ExtensionRegistry()
with registry.attributed_to("first:install"):
registry.service(first)
with registry.attributed_to("second:install"):
registry.service(second)
diagnostics = await stop_services(registry.build())
assert events == ["stop:second", "stop:first"]
assert [(diagnostic.source, diagnostic.level) for diagnostic in diagnostics] == [("second:install", "error")]
@pytest.mark.asyncio
async def test_service_originated_cancelled_error_does_not_abort_stop_batch():
from deerflow.extensions.gateway import stop_services
events: list[str] = []
class _CancelsItself(_Service):
async def stop(self) -> None:
self.events.append(f"stop:{self.name}")
raise asyncio.CancelledError()
first = _Service("first", events)
second = _CancelsItself("second", events)
registry = ExtensionRegistry()
with registry.attributed_to("first:install"):
registry.service(first)
with registry.attributed_to("second:install"):
registry.service(second)
diagnostics = await stop_services(registry.build())
assert events == ["stop:second", "stop:first"]
assert len(diagnostics) == 1
assert diagnostics[0].source == "second:install"
assert "CancelledError" in diagnostics[0].message
@pytest.mark.asyncio
async def test_each_service_stop_has_its_own_timeout_budget():
from deerflow.extensions.gateway import stop_services
events: list[str] = []
class _HangingService(_Service):
async def stop(self) -> None:
self.events.append(f"stop:{self.name}")
await asyncio.Event().wait()
first = _Service("first", events)
second = _HangingService("second", events)
registry = ExtensionRegistry()
with registry.attributed_to("first:install"):
registry.service(first)
with registry.attributed_to("second:install"):
registry.service(second)
diagnostics = await stop_services(registry.build(), timeout_seconds=0.01)
assert events == ["stop:second", "stop:first"]
assert len(diagnostics) == 1
assert diagnostics[0].source == "second:install"
assert "timed out" in diagnostics[0].message
@pytest.mark.asyncio
async def test_service_originated_timeout_error_is_reported_as_failure_not_budget_expiry():
from deerflow.extensions.gateway import stop_services
events: list[str] = []
class _RaisesTimeout(_Service):
async def stop(self) -> None:
self.events.append(f"stop:{self.name}")
raise TimeoutError("extension deadline")
first = _Service("first", events)
second = _RaisesTimeout("second", events)
registry = ExtensionRegistry()
with registry.attributed_to("first:install"):
registry.service(first)
with registry.attributed_to("second:install"):
registry.service(second)
diagnostics = await stop_services(registry.build(), timeout_seconds=1.0)
assert events == ["stop:second", "stop:first"]
assert diagnostics[0].source == "second:install"
assert "failed" in diagnostics[0].message
assert "timed out" not in diagnostics[0].message
@pytest.mark.parametrize(
("owner_path", "owner_protocol", "candidate_path", "candidate_protocol", "rejected"),
[
("/exact", "GET", "/exact", "GET", True),
("/items/{item_id}", "GET", "/items/{id}", "GET", True),
("/items/{item_id}", "GET", "/items/new", "GET", True),
("/items/{item_id}", "GET", "/items/prefix-{id}", "GET", True),
("/pre{tenant}", "GET", "/prefoo{id}", "GET", True),
("/items/new", "GET", "/items/{id}", "GET", False),
("/records/{value}", "GET", "/records/{id:int}", "GET", True),
("/records/{value:int}", "GET", "/records/0{id:int}", "GET", True),
("/records/{value:int}", "GET", "/records/new", "GET", False),
("/files/{rest:path}", "GET", "/files/{id:int}", "GET", True),
("/files/{rest:path}", "GET", "/files/{id}", "GET", False),
("/x/{rest:path}/tail", "GET", "/x/a/{id}/tail", "GET", False),
("/items/{item_id}", "GET", "/items/{id}", "POST", False),
("/live/{item_id}", "WS", "/live/{id}", "GET", False),
],
ids=[
"exact",
"renamed-parameter",
"dynamic-shadows-static",
"dynamic-shadows-compound",
"compound-trailing-str-shadows-narrower-compound",
"static-does-not-shadow-dynamic",
"str-covers-int",
"int-shadows-digit-compound",
"int-does-not-cover-static",
"path-covers-descendant",
"path-does-not-cover-newline-capable-str",
"nonterminal-path-does-not-cover-newline-capable-str",
"disjoint-http-methods",
"websocket-does-not-shadow-http",
],
)
def test_router_conflicts_follow_starlette_dispatch_order(
owner_path,
owner_protocol,
candidate_path,
candidate_protocol,
rejected,
):
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
app = FastAPI()
if owner_protocol == "WS":
app.add_api_websocket_route(owner_path, endpoint)
else:
app.add_api_route(owner_path, endpoint, methods=[owner_protocol])
router = APIRouter()
if candidate_protocol == "WS":
router.add_api_websocket_route(candidate_path, endpoint)
else:
router.add_api_route(candidate_path, endpoint, methods=[candidate_protocol])
registry = ExtensionRegistry()
with registry.attributed_to("candidate:install"):
registry.routers((router,))
diagnostics = include_contributed_routers(app, registry.build())
assert bool(diagnostics) is rejected
if rejected:
assert diagnostics[0].source == "candidate:install"
assert "host" in diagnostics[0].message
assert candidate_path in diagnostics[0].message
else:
assert any(getattr(route, "path", None) == candidate_path for route in app.routes)
@pytest.mark.parametrize("convertor_name", ["flip", "int"])
def test_re_registered_convertor_does_not_create_a_false_shadow(
monkeypatch,
convertor_name,
):
from fastapi import APIRouter, FastAPI
from starlette.convertors import CONVERTOR_TYPES, Convertor
from starlette.routing import Match
from deerflow.extensions.gateway import include_contributed_routers
class DigitsConvertor(Convertor[str]):
regex = "[0-9]+"
def convert(self, value: str) -> str:
return value
def to_string(self, value: str) -> str:
return value
class LettersConvertor(Convertor[str]):
regex = "[A-Z]+"
def convert(self, value: str) -> str:
return value
def to_string(self, value: str) -> str:
return value
async def endpoint(value: str):
return {"value": value}
monkeypatch.setitem(CONVERTOR_TYPES, convertor_name, DigitsConvertor())
route_path = f"/owned/{{value:{convertor_name}}}"
app = FastAPI()
app.add_api_route(route_path, endpoint, methods=["GET"])
owner = app.routes[-1]
monkeypatch.setitem(CONVERTOR_TYPES, convertor_name, LettersConvertor())
router = APIRouter()
router.add_api_route(route_path, endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("candidate:install"):
registry.routers((router,))
diagnostics = include_contributed_routers(app, registry.build())
assert diagnostics == []
candidate = app.routes[-1]
scope = {
"type": "http",
"path": "/owned/A",
"method": "GET",
"root_path": "",
}
assert owner.matches(scope)[0] is Match.NONE
assert candidate.matches(scope)[0] is Match.FULL
def test_router_claim_uses_converter_semantics_at_include_time(monkeypatch):
from fastapi import APIRouter, FastAPI
from starlette.convertors import CONVERTOR_TYPES, Convertor
from starlette.routing import Match
from deerflow.extensions.gateway import include_contributed_routers
class DigitsConvertor(Convertor[str]):
regex = "[0-9]+"
def convert(self, value: str) -> str:
return value
def to_string(self, value: str) -> str:
return value
class LettersConvertor(Convertor[str]):
regex = "[A-Z]+"
def convert(self, value: str) -> str:
return value
def to_string(self, value: str) -> str:
return value
async def endpoint(value: str):
return {"value": value}
monkeypatch.setitem(CONVERTOR_TYPES, "flip", DigitsConvertor())
route_path = "/owned/{value:flip}"
app = FastAPI()
app.add_api_route(route_path, endpoint, methods=["GET"])
owner = app.routes[-1]
router = APIRouter()
router.add_api_route(route_path, endpoint, methods=["GET"])
monkeypatch.setitem(CONVERTOR_TYPES, "flip", LettersConvertor())
registry = ExtensionRegistry()
with registry.attributed_to("candidate:install"):
registry.routers((router,))
diagnostics = include_contributed_routers(app, registry.build())
assert diagnostics == []
candidate = app.routes[-1]
scope = {
"type": "http",
"path": "/owned/A",
"method": "GET",
"root_path": "",
}
assert owner.matches(scope)[0] is Match.NONE
assert candidate.matches(scope)[0] is Match.FULL
def test_recompiled_converter_cannot_enter_a_public_namespace(monkeypatch):
from fastapi import APIRouter, FastAPI
from starlette.convertors import CONVERTOR_TYPES, Convertor
from deerflow.extensions.gateway import include_contributed_routers
class PublicPathConvertor(Convertor[str]):
regex = r"webhooks/.+"
def convert(self, value: str) -> str:
return value
def to_string(self, value: str) -> str:
return value
async def endpoint(value: str):
return {"value": value}
router = APIRouter()
router.add_api_route("/api/{value:int}", endpoint, methods=["GET"])
monkeypatch.setitem(CONVERTOR_TYPES, "int", PublicPathConvertor())
registry = ExtensionRegistry()
with registry.attributed_to("candidate:install"):
registry.routers((router,))
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["candidate:install"]
assert "public namespace" in diagnostics[0].message
assert not any(getattr(route, "path", None) == "/api/{value:int}" for route in app.routes)
def test_host_mount_claims_descendant_http_paths():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
app = FastAPI()
app.mount("/assets", FastAPI())
router = APIRouter()
@router.get("/assets/{name:int}")
async def asset(name: int):
return {"name": name}
registry = ExtensionRegistry()
with registry.attributed_to("assets:install"):
registry.routers((router,))
diagnostics = include_contributed_routers(app, registry.build())
assert len(diagnostics) == 1
assert diagnostics[0].source == "assets:install"
assert "host" in diagnostics[0].message
def test_dynamic_host_mount_claims_matching_descendants():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
app = FastAPI()
app.mount("/pre{tenant}", FastAPI())
router = APIRouter()
@router.get("/prefoo/{item_id:int}")
async def item(item_id: int):
return {"item_id": item_id}
registry = ExtensionRegistry()
with registry.attributed_to("mount:install"):
registry.routers((router,))
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["mount:install"]
assert "host" in diagnostics[0].message
@pytest.mark.parametrize(
("mount_path", "candidate_path", "witness"),
[
("/assets", "/assets/{name}", "/assets/a\nb"),
("/pre{tenant}", "/prefoo{id}/{child}", "/prefoo1/a\nb"),
],
)
def test_host_mount_does_not_claim_newline_capable_str_descendants(
mount_path,
candidate_path,
witness,
):
from fastapi import APIRouter, FastAPI
from starlette.routing import Match, Mount
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
app = FastAPI()
app.mount(mount_path, FastAPI())
host_mount = next(route for route in app.routes if isinstance(route, Mount) and route.path == mount_path)
router = APIRouter()
router.add_api_route(candidate_path, endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("mount:install"):
registry.routers((router,))
diagnostics = include_contributed_routers(app, registry.build())
assert diagnostics == []
candidate_route = next(route for route in app.routes if getattr(route, "path", None) == candidate_path)
scope = {
"type": "http",
"path": witness,
"method": "GET",
"root_path": "",
}
assert host_mount.matches(scope)[0] is Match.NONE
assert candidate_route.matches(scope)[0] is Match.FULL
def test_contributed_websocket_route_is_rejected_until_host_auth_wraps_it():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def websocket_endpoint(websocket):
await websocket.close()
router = APIRouter()
router.add_api_websocket_route("/extension-ws", websocket_endpoint)
registry = ExtensionRegistry()
with registry.attributed_to("websocket:install"):
registry.routers((router,))
app = FastAPI()
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["websocket:install"]
assert "WebSocket" in diagnostics[0].message
assert not any(getattr(route, "path", None) == "/extension-ws" for route in app.routes)
@pytest.mark.parametrize(
"path",
[
"/health-extension",
"/docs-private",
"/redoc-private",
"/api/webhooks/extension",
"/api/{rest:path}",
"/api/{section}/extension",
],
)
def test_extension_routes_cannot_enter_host_public_namespaces(path):
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
router = APIRouter()
router.add_api_route(path, endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("public:install"):
registry.routers((router,))
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["public:install"]
assert "public namespace" in diagnostics[0].message
assert not any(getattr(route, "path", None) == path for route in app.routes)
@pytest.mark.parametrize(
"path",
[
"/api",
"/heal",
"/api/{item_id:int}",
"/api/{item_id}",
],
)
def test_extension_routes_that_cannot_enter_a_public_namespace_are_allowed(path):
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
router = APIRouter()
router.add_api_route(path, endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("private:install"):
registry.routers((router,))
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
diagnostics = include_contributed_routers(app, registry.build())
assert diagnostics == []
assert any(getattr(route, "path", None) == path for route in app.routes)
def test_unknown_convertor_near_a_public_namespace_fails_closed(monkeypatch):
from fastapi import APIRouter, FastAPI
from starlette.convertors import CONVERTOR_TYPES, Convertor
from deerflow.extensions.gateway import include_contributed_routers
class UppercaseConvertor(Convertor[str]):
regex = "[A-Z]+"
def convert(self, value: str) -> str:
return value
def to_string(self, value: str) -> str:
return value
monkeypatch.setitem(CONVERTOR_TYPES, "uppercase", UppercaseConvertor())
router = APIRouter()
router.add_api_route(
"/api/{value:uppercase}",
lambda: {"ok": True},
methods=["GET"],
)
registry = ExtensionRegistry()
with registry.attributed_to("custom-public:install"):
registry.routers((router,))
diagnostics = include_contributed_routers(
FastAPI(docs_url=None, redoc_url=None, openapi_url=None),
registry.build(),
)
assert [diagnostic.source for diagnostic in diagnostics] == ["custom-public:install"]
assert "public namespace" in diagnostics[0].message
def test_private_custom_convertor_with_named_backreference_is_allowed(monkeypatch):
from fastapi import APIRouter, FastAPI
from starlette.convertors import CONVERTOR_TYPES, Convertor
from deerflow.extensions.gateway import include_contributed_routers
class DoubledLetterConvertor(Convertor[str]):
regex = r"(?P<char>[A-Z])(?P=char)"
def convert(self, value: str) -> str:
return value
def to_string(self, value: str) -> str:
return value
monkeypatch.setitem(CONVERTOR_TYPES, "doubled", DoubledLetterConvertor())
router = APIRouter()
router.add_api_route(
"/private/{value:doubled}",
lambda value: {"value": value},
methods=["GET"],
)
registry = ExtensionRegistry()
with registry.attributed_to("custom-private:install"):
registry.routers((router,))
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
diagnostics = include_contributed_routers(app, registry.build())
assert diagnostics == []
assert any(getattr(route, "path", None) == "/private/{value:doubled}" for route in app.routes)
def test_extension_public_paths_track_auth_middleware_public_paths():
from app.gateway.auth_middleware import (
_PUBLIC_EXACT_PATHS,
_PUBLIC_PATH_PREFIXES,
_is_public,
)
from deerflow.extensions.gateway import (
_HOST_PUBLIC_EXACT_PATHS,
_HOST_PUBLIC_PATH_PREFIXES,
)
assert _HOST_PUBLIC_PATH_PREFIXES == _PUBLIC_PATH_PREFIXES
assert _HOST_PUBLIC_EXACT_PATHS == _PUBLIC_EXACT_PATHS
assert all(_is_public(f"{path}//") for path in _HOST_PUBLIC_EXACT_PATHS)
@pytest.mark.parametrize(
("host_path", "host_method", "candidate_path", "candidate_method"),
[
(
"/api/v1/auth/login/local",
"POST",
"/api/v1/auth/login/local",
"GET",
),
(
"/api/v1/auth/login/local",
"POST",
"/api/v1/auth/login/local/",
"GET",
),
(
"/api/v1/auth/login/local",
"POST",
"/api/v1/auth/login/local//",
"GET",
),
("/api/v1/auth/me", "GET", "/api/v1/auth/me", "POST"),
("/api/v1/auth/me", "GET", "/api/v1/auth/me/", "POST"),
],
)
def test_extension_routes_cannot_claim_reserved_exact_paths_with_a_disjoint_method(
host_path,
host_method,
candidate_path,
candidate_method,
):
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
app = FastAPI()
app.add_api_route(host_path, endpoint, methods=[host_method])
router = APIRouter()
router.add_api_route(candidate_path, endpoint, methods=[candidate_method])
registry = ExtensionRegistry()
with registry.attributed_to("public-exact:install"):
registry.routers((router,))
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["public-exact:install"]
assert "reserved" in diagnostics[0].message
assert not any(getattr(route, "path", None) == candidate_path and getattr(route, "methods", set()) == {candidate_method} for route in app.routes)
@pytest.mark.parametrize(
"path",
[
"/api/v1/auth/me",
"/api/v1/auth/me/",
"/api/v1/auth/me//",
],
)
def test_extension_csrf_reserved_exact_paths_track_csrf_exemption(
monkeypatch,
path,
):
from starlette.requests import Request
from app.gateway import csrf_middleware
from deerflow.extensions.gateway import (
_CSRF_STATE_CHANGING_METHODS,
_HOST_CSRF_EXEMPT_EXACT_PATHS,
_HOST_PUBLIC_EXACT_PATHS,
)
monkeypatch.setattr(csrf_middleware, "is_auth_disabled", lambda: False)
request = Request(
{
"type": "http",
"method": "POST",
"scheme": "http",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": [],
"server": ("testserver", 80),
}
)
assert path.rstrip("/") in _HOST_CSRF_EXEMPT_EXACT_PATHS
assert _HOST_CSRF_EXEMPT_EXACT_PATHS == csrf_middleware._CSRF_EXEMPT_EXACT_PATHS
assert _CSRF_STATE_CHANGING_METHODS == csrf_middleware._CSRF_STATE_CHANGING_METHODS
assert csrf_middleware._AUTH_EXEMPT_PATHS <= _HOST_PUBLIC_EXACT_PATHS
assert csrf_middleware.should_check_csrf(request) is False
def test_safe_method_at_csrf_exempt_exact_path_is_not_reserved():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
router = APIRouter()
router.add_api_route(
"/api/v1/auth/me",
lambda: {"ok": True},
methods=["GET"],
)
registry = ExtensionRegistry()
with registry.attributed_to("safe-csrf:install"):
registry.routers((router,))
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
diagnostics = include_contributed_routers(app, registry.build())
assert diagnostics == []
assert any(getattr(route, "path", None) == "/api/v1/auth/me" for route in app.routes)
def test_router_with_one_conflict_is_rejected_atomically_and_names_first_owner():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
first = APIRouter()
first.add_api_route("/shared", endpoint, methods=["GET"])
second = APIRouter()
second.add_api_route("/would-have-been-reachable", endpoint, methods=["GET"])
second.add_api_route("/shared", endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("first:install"):
registry.routers((first,))
with registry.attributed_to("second:install"):
registry.routers((second,))
app = FastAPI()
diagnostics = include_contributed_routers(app, registry.build())
paths = [getattr(route, "path", None) for route in app.routes]
assert "/shared" in paths
assert "/would-have-been-reachable" not in paths
assert len(diagnostics) == 1
assert diagnostics[0].source == "second:install"
assert "first:install" in diagnostics[0].message
def test_router_is_rejected_when_its_own_earlier_route_shadows_a_later_one():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
router = APIRouter()
router.add_api_route("/same/{value}", endpoint, methods=["GET"])
router.add_api_route("/same/fixed", endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("self-shadow:install"):
registry.routers((router,))
app = FastAPI()
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["self-shadow:install"]
assert "self-shadow:install" in diagnostics[0].message
assert not any(getattr(route, "path", "").startswith("/same/") for route in app.routes)
def test_contributed_mount_is_rejected_but_does_not_starve_later_router():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
bad = APIRouter()
bad.mount("/nested", FastAPI())
good = APIRouter()
@good.get("/extension-good")
async def extension_good():
return {"ok": True}
registry = ExtensionRegistry()
with registry.attributed_to("bad:install"):
registry.routers((bad,))
with registry.attributed_to("good:install"):
registry.routers((good,))
app = FastAPI()
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["bad:install"]
assert "Mount" in diagnostics[0].message
assert any(getattr(route, "path", None) == "/extension-good" for route in app.routes)
def test_router_with_unsupported_route_item_is_rejected_atomically():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
router = APIRouter()
router.add_api_route("/otherwise-valid", endpoint, methods=["GET"])
router.routes.append(object())
registry = ExtensionRegistry()
with registry.attributed_to("unsupported:install"):
registry.routers((router,))
app = FastAPI()
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["unsupported:install"]
assert "unsupported route" in diagnostics[0].message
assert not any(getattr(route, "path", None) == "/otherwise-valid" for route in app.routes)
def test_include_router_failure_rolls_back_partial_routes_before_continuing():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
broken = APIRouter()
broken.add_api_route("/partial", endpoint, methods=["GET"])
broken.add_api_route("/explodes", endpoint, methods=["GET"])
broken.routes[-1].endpoint = None
later = APIRouter()
later.add_api_route("/partial", endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("broken:install"):
registry.routers((broken,))
with registry.attributed_to("later:install"):
registry.routers((later,))
app = FastAPI()
diagnostics = include_contributed_routers(app, registry.build())
partial_routes = [route for route in app.routes if getattr(route, "path", None) == "/partial"]
assert [diagnostic.source for diagnostic in diagnostics] == ["broken:install"]
assert len(partial_routes) == 1
def test_router_lifecycle_hooks_are_rejected_in_favor_of_extension_service():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
async def endpoint():
return {"ok": True}
async def startup_hook():
raise RuntimeError("must never be installed")
bad = APIRouter()
bad.add_api_route("/has-lifecycle", endpoint, methods=["GET"])
bad.add_event_handler("startup", startup_hook)
good = APIRouter()
good.add_api_route("/after-lifecycle", endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("lifecycle:install"):
registry.routers((bad,))
with registry.attributed_to("good:install"):
registry.routers((good,))
app = FastAPI()
diagnostics = include_contributed_routers(app, registry.build())
paths = [getattr(route, "path", None) for route in app.routes]
assert [diagnostic.source for diagnostic in diagnostics] == ["lifecycle:install"]
assert "ExtensionService" in diagnostics[0].message
assert "/has-lifecycle" not in paths
assert "/after-lifecycle" in paths
assert startup_hook not in app.router.on_startup
def test_router_custom_lifespan_is_rejected_in_favor_of_extension_service():
from fastapi import APIRouter, FastAPI
from deerflow.extensions.gateway import include_contributed_routers
@asynccontextmanager
async def lifespan(_app):
yield
async def endpoint():
return {"ok": True}
router = APIRouter(lifespan=lifespan)
router.add_api_route("/custom-lifespan", endpoint, methods=["GET"])
registry = ExtensionRegistry()
with registry.attributed_to("lifespan:install"):
registry.routers((router,))
app = FastAPI()
diagnostics = include_contributed_routers(app, registry.build())
assert [diagnostic.source for diagnostic in diagnostics] == ["lifespan:install"]
assert "ExtensionService" in diagnostics[0].message
assert not any(getattr(route, "path", None) == "/custom-lifespan" for route in app.routes)