]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
🐛 Fix bug, allow empty path in path operation in prefixless router (#15763)
authorSebastián Ramírez <tiangolo@gmail.com>
Mon, 15 Jun 2026 10:55:06 +0000 (12:55 +0200)
committerGitHub <noreply@github.com>
Mon, 15 Jun 2026 10:55:06 +0000 (12:55 +0200)
fastapi/routing.py
tests/test_router_include_context.py

index fb47843099e4d5269ff07b27bdb129e4db196e7f..f33b964794f243ed5b6d5a5da3213d8b73ca5b95 100644 (file)
@@ -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})"
index 408cdd3f11d7f8eee0460e9d28d276abad65778e..c2679aa1176d77adb9250dc154752066bf77d336 100644 (file)
@@ -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()