diff --git a/fastapi/routing.py b/fastapi/routing.py index fb4784309..f33b96479 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -2435,9 +2435,16 @@ class APIRouter(routing.Router): "A path prefix must not end with '/', as the routes will start with '/'" ) else: - for r in _iter_included_route_candidates(router.routes): - path = getattr(r, "path", None) - name = getattr(r, "name", "unknown") + for route, route_context in _iter_routes_with_context(router.routes): + if route_context is None: + path = getattr(route, "path", None) + name = getattr(route, "name", "unknown") + elif route_context.starlette_route is not None: + path = getattr(route_context.starlette_route, "path", None) + name = getattr(route_context.starlette_route, "name", "unknown") + else: + path = route_context.path + name = route_context.name if path is not None and not path: raise FastAPIError( f"Prefix and path cannot be both empty (path operation: {name})" diff --git a/tests/test_router_include_context.py b/tests/test_router_include_context.py index 408cdd3f1..c2679aa11 100644 --- a/tests/test_router_include_context.py +++ b/tests/test_router_include_context.py @@ -2,6 +2,7 @@ from typing import Annotated, cast import pytest from fastapi import APIRouter, Body, Depends, FastAPI, Request +from fastapi.exceptions import FastAPIError from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.routing import ( APIRoute, @@ -807,6 +808,60 @@ def test_no_prefix_include_validation_sees_effective_starlette_route_candidates( assert cast(Route, candidates[0]).path == "/child/items" +def test_no_prefix_include_validation_sees_effective_api_route_path(): + leaf_router = APIRouter() + + @leaf_router.get("") + def read_items(): + return [] + + parent_router = APIRouter() + parent_router.include_router(leaf_router, prefix="/items") + + # for coverage + candidates = list(_iter_included_route_candidates(parent_router.routes)) + assert cast(APIRoute, candidates[0]).path == "" + + app = FastAPI() + app.include_router(parent_router) + client = TestClient(app) + + response = client.get("/items") + + assert response.status_code == 200, response.text + assert response.json() == [] + + +def test_no_prefix_include_validation_sees_effective_starlette_route_path(): + def endpoint(request): + return PlainTextResponse("ok") + + child_router = APIRouter(routes=[Route("/items", endpoint, name="read_items")]) + parent_router = APIRouter() + parent_router.include_router(child_router, prefix="/child") + + app = FastAPI() + app.include_router(parent_router) + client = TestClient(app) + + response = client.get("/child/items") + + assert response.status_code == 200, response.text + assert response.text == "ok" + + +def test_no_prefix_include_validation_rejects_empty_effective_api_route_path(): + router = APIRouter() + + @router.get("") + def read_items(): # pragma: no cover + return [] + + app = FastAPI() + with pytest.raises(FastAPIError): + app.include_router(router) + + def test_apirouter_matches_fallback_without_include_context(): router = APIRouter()