This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new a26f9f9926c Fix missing and incorrect endpoints in the API permission 
reference (#74871)
a26f9f9926c is described below

commit a26f9f9926c29d9126fc5ca5f33b4e724c8bc509
Author: Pushkal Gupta <[email protected]>
AuthorDate: Mon Oct 19 19:30:59 2026 +0530

    Fix missing and incorrect endpoints in the API permission reference (#74871)
---
 airflow-core/docs/security/api_permissions_ref.rst |  54 +++++---
 providers/fab/docs/auth-manager/access-control.rst |  54 +++++---
 scripts/ci/prek/extract_permissions.py             |  35 ++++-
 scripts/ci/prek/fab_permissions_doc.py             |  13 +-
 scripts/tests/ci/prek/test_extract_permissions.py  | 148 +++++++++++++++++++++
 5 files changed, 263 insertions(+), 41 deletions(-)

diff --git a/airflow-core/docs/security/api_permissions_ref.rst 
b/airflow-core/docs/security/api_permissions_ref.rst
index cf5e188ff15..9d47f6bba3b 100644
--- a/airflow-core/docs/security/api_permissions_ref.rst
+++ b/airflow-core/docs/security/api_permissions_ref.rst
@@ -99,15 +99,15 @@ source code so it stays up to date as endpoints are added 
or changed.
      - ``Asset``
      - ``GET``
    * - ``DELETE``
-     - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+     - ``/api/v2/assets/{asset_id}/state-store/{key}``
      - ``Asset``
      - ``DELETE``
    * - ``GET``
-     - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+     - ``/api/v2/assets/{asset_id}/state-store/{key}``
      - ``Asset``
      - ``GET``
    * - ``PUT``
-     - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+     - ``/api/v2/assets/{asset_id}/state-store/{key}``
      - ``Asset``
      - ``PUT``
    * - ``GET``
@@ -126,8 +126,28 @@ source code so it stays up to date as endpoints are added 
or changed.
      - ``/api/v2/backfills``
      - ``DAG.RUN``
      - ``POST``
+   * - ``POST``
+     - ``/api/v2/backfills/dry_run``
+     - ``DAG.RUN``
+     - ``POST``
+   * - ``GET``
+     - ``/api/v2/backfills/{backfill_id}``
+     - ``DAG.RUN``
+     - ``GET``
    * - ``PUT``
-     - ``/api/v2/backfills``
+     - ``/api/v2/backfills/{backfill_id}/cancel``
+     - ``DAG.RUN``
+     - ``PUT``
+   * - ``GET``
+     - ``/api/v2/backfills/{backfill_id}/dag_runs``
+     - ``DAG.RUN``
+     - ``GET``
+   * - ``PUT``
+     - ``/api/v2/backfills/{backfill_id}/pause``
+     - ``DAG.RUN``
+     - ``PUT``
+   * - ``PUT``
+     - ``/api/v2/backfills/{backfill_id}/unpause``
      - ``DAG.RUN``
      - ``PUT``
    * - ``GET``
@@ -375,19 +395,19 @@ source code so it stays up to date as endpoints are added 
or changed.
      - ``DAG.TASK_INSTANCE``
      - ``GET``
    * - ``DELETE``
-     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}``
      - ``DAG.TASK_INSTANCE``
      - ``DELETE``
    * - ``GET``
-     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}``
      - ``DAG.TASK_INSTANCE``
      - ``GET``
    * - ``PATCH``
-     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}``
      - ``DAG.TASK_INSTANCE``
      - ``PUT``
    * - ``PUT``
-     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}``
      - ``DAG.TASK_INSTANCE``
      - ``PUT``
    * - ``GET``
@@ -407,15 +427,15 @@ source code so it stays up to date as endpoints are added 
or changed.
      - ``DAG.XCOM``
      - ``POST``
    * - ``DELETE``
-     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key}``
      - ``DAG.XCOM``
      - ``DELETE``
    * - ``GET``
-     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key}``
      - ``DAG.XCOM``
      - ``GET``
    * - ``PATCH``
-     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+     - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key}``
      - ``DAG.XCOM``
      - ``PUT``
    * - ``GET``
@@ -547,15 +567,15 @@ source code so it stays up to date as endpoints are added 
or changed.
      - ``Pool``
      - ``POST``
    * - ``DELETE``
-     - ``/api/v2/pools/{pool_name:path}``
+     - ``/api/v2/pools/{pool_name}``
      - ``Pool``
      - ``DELETE``
    * - ``GET``
-     - ``/api/v2/pools/{pool_name:path}``
+     - ``/api/v2/pools/{pool_name}``
      - ``Pool``
      - ``GET``
    * - ``PATCH``
-     - ``/api/v2/pools/{pool_name:path}``
+     - ``/api/v2/pools/{pool_name}``
      - ``Pool``
      - ``PUT``
    * - ``GET``
@@ -575,15 +595,15 @@ source code so it stays up to date as endpoints are added 
or changed.
      - ``Variable``
      - ``POST``
    * - ``DELETE``
-     - ``/api/v2/variables/{variable_key:path}``
+     - ``/api/v2/variables/{variable_key}``
      - ``Variable``
      - ``DELETE``
    * - ``GET``
-     - ``/api/v2/variables/{variable_key:path}``
+     - ``/api/v2/variables/{variable_key}``
      - ``Variable``
      - ``GET``
    * - ``PATCH``
-     - ``/api/v2/variables/{variable_key:path}``
+     - ``/api/v2/variables/{variable_key}``
      - ``Variable``
      - ``PUT``
    * - ``GET``
diff --git a/providers/fab/docs/auth-manager/access-control.rst 
b/providers/fab/docs/auth-manager/access-control.rst
index 65713c2c5dd..551e1c3c1c2 100644
--- a/providers/fab/docs/auth-manager/access-control.rst
+++ b/providers/fab/docs/auth-manager/access-control.rst
@@ -272,15 +272,15 @@ Stable API Permissions
      - GET
      - Assets.can_read
      - Viewer
-   * - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+   * - ``/api/v2/assets/{asset_id}/state-store/{key}``
      - DELETE
      - Assets.can_delete
      - Op
-   * - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+   * - ``/api/v2/assets/{asset_id}/state-store/{key}``
      - GET
      - Assets.can_read
      - Viewer
-   * - ``/api/v2/assets/{asset_id}/state-store/{key:path}``
+   * - ``/api/v2/assets/{asset_id}/state-store/{key}``
      - PUT
      - Assets.can_edit
      - Op
@@ -300,7 +300,27 @@ Stable API Permissions
      - POST
      - DAGs.can_edit, DAG Runs.can_create
      - User
-   * - ``/api/v2/backfills``
+   * - ``/api/v2/backfills/dry_run``
+     - POST
+     - DAGs.can_edit, DAG Runs.can_create
+     - User
+   * - ``/api/v2/backfills/{backfill_id}``
+     - GET
+     - DAGs.can_read, DAG Runs.can_read
+     - Viewer
+   * - ``/api/v2/backfills/{backfill_id}/cancel``
+     - PUT
+     - DAGs.can_edit, DAG Runs.can_edit
+     - User
+   * - ``/api/v2/backfills/{backfill_id}/dag_runs``
+     - GET
+     - DAGs.can_read, DAG Runs.can_read
+     - Viewer
+   * - ``/api/v2/backfills/{backfill_id}/pause``
+     - PUT
+     - DAGs.can_edit, DAG Runs.can_edit
+     - User
+   * - ``/api/v2/backfills/{backfill_id}/unpause``
      - PUT
      - DAGs.can_edit, DAG Runs.can_edit
      - User
@@ -548,19 +568,19 @@ Stable API Permissions
      - GET
      - DAGs.can_read, DAG Runs.can_read, Task Instances.can_read
      - Viewer
-   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}``
      - DELETE
      - DAGs.can_edit, DAG Runs.can_delete, Task Instances.can_delete
      - User
-   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}``
      - GET
      - DAGs.can_read, DAG Runs.can_read, Task Instances.can_read
      - Viewer
-   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}``
      - PATCH
      - DAGs.can_edit, DAG Runs.can_edit, Task Instances.can_edit
      - User
-   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key:path}``
+   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}``
      - PUT
      - DAGs.can_edit, DAG Runs.can_edit, Task Instances.can_edit
      - User
@@ -580,15 +600,15 @@ Stable API Permissions
      - POST
      - DAGs.can_edit, XComs.can_create
      - Op
-   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key}``
      - DELETE
      - DAGs.can_edit, XComs.can_delete
      - Op
-   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key}``
      - GET
      - DAGs.can_read, XComs.can_read
      - Viewer
-   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key:path}``
+   * - 
``/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{xcom_key}``
      - PATCH
      - DAGs.can_edit, XComs.can_edit
      - Op
@@ -720,15 +740,15 @@ Stable API Permissions
      - POST
      - Pools.can_create
      - Op
-   * - ``/api/v2/pools/{pool_name:path}``
+   * - ``/api/v2/pools/{pool_name}``
      - DELETE
      - Pools.can_delete
      - Op
-   * - ``/api/v2/pools/{pool_name:path}``
+   * - ``/api/v2/pools/{pool_name}``
      - GET
      - Pools.can_read
      - Viewer
-   * - ``/api/v2/pools/{pool_name:path}``
+   * - ``/api/v2/pools/{pool_name}``
      - PATCH
      - Pools.can_edit
      - Op
@@ -748,15 +768,15 @@ Stable API Permissions
      - POST
      - Variables.can_create
      - Op
-   * - ``/api/v2/variables/{variable_key:path}``
+   * - ``/api/v2/variables/{variable_key}``
      - DELETE
      - Variables.can_delete
      - Op
-   * - ``/api/v2/variables/{variable_key:path}``
+   * - ``/api/v2/variables/{variable_key}``
      - GET
      - Variables.can_read
      - Op
-   * - ``/api/v2/variables/{variable_key:path}``
+   * - ``/api/v2/variables/{variable_key}``
      - PATCH
      - Variables.can_edit
      - Op
diff --git a/scripts/ci/prek/extract_permissions.py 
b/scripts/ci/prek/extract_permissions.py
index b3f86e55a07..f2a14ab5879 100644
--- a/scripts/ci/prek/extract_permissions.py
+++ b/scripts/ci/prek/extract_permissions.py
@@ -33,6 +33,7 @@ from __future__ import annotations
 
 import ast
 import pathlib
+import re
 import sys
 from dataclasses import dataclass
 
@@ -95,6 +96,34 @@ def _resolve_string_node(node: ast.expr, module_consts: 
dict[str, str]) -> str:
     return f"<unresolved:{ast.unparse(node)}>"
 
 
+def strip_path_converters(path: str) -> str:
+    """
+    Drop Starlette path converters from a route path.
+
+    A route declared as ``/{pool_name:path}`` is served, and documented in the
+    OpenAPI spec, as ``/{pool_name}``. The converter only tells Starlette how 
to
+    match the segment, so leaving it in prints a URL that no client ever calls.
+    """
+    return re.sub(r"\{([^{}:]+):[^{}]+\}", r"{\1}", path)
+
+
+def _extract_route_path(decorator: ast.Call, module_consts: dict[str, str]) -> 
str:
+    """
+    Resolve the route path from a route decorator.
+
+    FastAPI accepts the path either positionally or as ``path=``, and both 
forms
+    are in use here. Reading only the positional form silently resolves the 
whole
+    file to its router prefix, which collapses every route onto one bogus row
+    instead of failing.
+    """
+    if decorator.args:
+        return _resolve_string_node(decorator.args[0], module_consts)
+    path_kwarg = next((kw for kw in decorator.keywords if kw.arg == "path"), 
None)
+    if path_kwarg is not None:
+        return _resolve_string_node(path_kwarg.value, module_consts)
+    raise RouteParseError(f"Route decorator has no path: 
{ast.unparse(decorator)}")
+
+
 def _extract_module_string_constants(tree: ast.Module) -> dict[str, str]:
     """
     Walk top-level assignments and collect simple string assignments.
@@ -313,9 +342,7 @@ def extract_from_file(path: pathlib.Path) -> 
list[PermissionEntry]:
                 continue
 
             # Resolve the route path
-            route_suffix = ""
-            if decorator.args:
-                route_suffix = _resolve_string_node(decorator.args[0], 
module_consts)
+            route_suffix = _extract_route_path(decorator, module_consts)
 
             # Resolve the prefix based on the router variable used in the 
decorator
             router_prefix = ""
@@ -323,7 +350,7 @@ def extract_from_file(path: pathlib.Path) -> 
list[PermissionEntry]:
                 router_var = decorator.func.value.id
                 router_prefix = routers.get(router_var, "")
 
-            full_path = API_PREFIX + router_prefix + route_suffix
+            full_path = strip_path_converters(API_PREFIX + router_prefix + 
route_suffix)
 
             # Extract tag (for grouping in the RST table)
             tag = _extract_tag_from_decorator(decorator)
diff --git a/scripts/ci/prek/fab_permissions_doc.py 
b/scripts/ci/prek/fab_permissions_doc.py
index 3bbbdf075b9..d3876665f91 100755
--- a/scripts/ci/prek/fab_permissions_doc.py
+++ b/scripts/ci/prek/fab_permissions_doc.py
@@ -46,6 +46,7 @@ from extract_permissions import (
     PermissionEntry,
     RouteParseError,
     extract_all_permissions,
+    strip_path_converters,
 )
 
 REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
@@ -303,7 +304,7 @@ def _fab_route_entries() -> list[tuple[str, str, str, str]]:
         if route_file.name in {"__init__.py", "router.py"}:
             continue
         for node in ast.walk(_parse(route_file)):
-            if not isinstance(node, ast.FunctionDef):
+            if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                 continue
             for deco in node.decorator_list:
                 if not isinstance(deco, ast.Call):
@@ -311,11 +312,17 @@ def _fab_route_entries() -> list[tuple[str, str, str, 
str]]:
                 verb = _name_of(deco.func)
                 if verb is None or verb.upper() not in _METHOD_TO_ACTION:
                     continue
-                if not deco.args or not isinstance(deco.args[0], ast.Constant):
+                # FastAPI takes the path positionally or as ``path=``; accept 
both so a
+                # route written the other way does not quietly drop out of the 
table.
+                path_node = deco.args[0] if deco.args else None
+                if path_node is None:
+                    path_node = next((kw.value for kw in deco.keywords if 
kw.arg == "path"), None)
+                if not isinstance(path_node, ast.Constant):
                     continue
-                path = deco.args[0].value
+                path = path_node.value
                 if not isinstance(path, str):
                     continue
+                path = strip_path_converters(path)
 
                 perms: list[str] = []
                 for kw in deco.keywords:
diff --git a/scripts/tests/ci/prek/test_extract_permissions.py 
b/scripts/tests/ci/prek/test_extract_permissions.py
index 269f2989877..0ffef7b1bb6 100644
--- a/scripts/tests/ci/prek/test_extract_permissions.py
+++ b/scripts/tests/ci/prek/test_extract_permissions.py
@@ -32,6 +32,7 @@ Run with (no Airflow env needed — extractor is stdlib-only):
 from __future__ import annotations
 
 import ast
+import re
 import sys
 import textwrap
 from pathlib import Path
@@ -54,11 +55,13 @@ from extract_permissions import (  # noqa: E402
     _build_resource_label,
     _extract_method_arg,
     _extract_module_string_constants,
+    _extract_route_path,
     _extract_routers,
     _resolve_string_node,
     extract_all_permissions,
     extract_from_file,
     render_rst,
+    strip_path_converters,
 )
 
 PUBLIC_ROUTES_DIR = REPO_ROOT / 
"airflow-core/src/airflow/api_fastapi/core_api/routes/public"
@@ -293,6 +296,59 @@ class TestBuildResourceLabel:
         assert _build_resource_label("requires_access_new_thing", None) == 
"requires_access_new_thing"
 
 
+# ===========================================================================
+# Unit tests: strip_path_converters
+# ===========================================================================
+
+
+class TestStripPathConverters:
+    @pytest.mark.parametrize(
+        "declared, served",
+        [
+            ("/api/v2/pools/{pool_name:path}", "/api/v2/pools/{pool_name}"),
+            ("/api/v2/variables/{variable_key:path}", 
"/api/v2/variables/{variable_key}"),
+            # more than one parameter, only the converted one changes
+            (
+                "/api/v2/assets/{asset_id}/state-store/{key:path}",
+                "/api/v2/assets/{asset_id}/state-store/{key}",
+            ),
+            # converters other than :path are normalised the same way
+            ("/api/v2/dags/{dag_id:str}/runs/{n:int}", 
"/api/v2/dags/{dag_id}/runs/{n}"),
+            # nothing to do
+            ("/api/v2/dags/{dag_id}", "/api/v2/dags/{dag_id}"),
+            ("/api/v2/backfills", "/api/v2/backfills"),
+        ],
+    )
+    def test_converter_is_dropped(self, declared, served):
+        assert strip_path_converters(declared) == served
+
+
+# ===========================================================================
+# Unit tests: _extract_route_path
+# ===========================================================================
+
+
+class TestExtractRoutePath:
+    def test_positional_path(self):
+        deco = parse_expr('router.get("/{dag_id}", dependencies=[])')
+        assert _extract_route_path(deco, {}) == "/{dag_id}"
+
+    def test_keyword_path(self):
+        """``backfills.py`` passes the path as ``path=``, which must resolve 
the same."""
+        deco = parse_expr('router.get(path="/{backfill_id}/pause", 
dependencies=[])')
+        assert _extract_route_path(deco, {}) == "/{backfill_id}/pause"
+
+    def test_keyword_path_resolves_module_constants(self):
+        deco = parse_expr('router.get(path=my_prefix + "/{id}")')
+        assert _extract_route_path(deco, {"my_prefix": "/things"}) == 
"/things/{id}"
+
+    def test_no_path_at_all_raises(self):
+        """Falling back to an empty suffix would collapse the route onto its 
router prefix."""
+        deco = parse_expr("router.get(dependencies=[])")
+        with pytest.raises(RouteParseError):
+            _extract_route_path(deco, {})
+
+
 # ===========================================================================
 # Unit tests: extract_from_file (synthetic route files)
 # ===========================================================================
@@ -538,6 +594,57 @@ class TestExtractFromFile:
         assert len(entries) == 1
         assert entries[0].resource == "DAG"
 
+    def test_keyword_path_is_not_collapsed_onto_the_router_prefix(self, 
tmp_path):
+        """
+        Mirrors ``backfills.py``, which declares every route as ``path=``.
+
+        Reading only the positional argument left each of these resolving to 
the
+        bare router prefix, so eight endpoints became three wrong rows.
+        """
+        f = _make_route_file(
+            tmp_path,
+            """
+            from fastapi import Depends
+            backfills_router = AirflowRouter(tags=["Backfill"], 
prefix="/backfills")
+
+            @backfills_router.get(
+                path="/{backfill_id}",
+                dependencies=[Depends(requires_access_backfill(method="GET"))],
+            )
+            def get_backfill(backfill_id: int): ...
+
+            @backfills_router.put(
+                path="/{backfill_id}/pause",
+                dependencies=[Depends(requires_access_backfill(method="PUT"))],
+            )
+            def pause_backfill(backfill_id: int): ...
+            """,
+        )
+        entries = extract_from_file(f)
+        assert {(e.http_method, e.full_path) for e in entries} == {
+            ("GET", "/api/v2/backfills/{backfill_id}"),
+            ("PUT", "/api/v2/backfills/{backfill_id}/pause"),
+        }
+
+    def test_path_converter_is_stripped_from_the_documented_path(self, 
tmp_path):
+        """The served URL is ``/pools/{pool_name}``; the converter is matching 
syntax."""
+        f = _make_route_file(
+            tmp_path,
+            """
+            from fastapi import Depends
+            pools_router = AirflowRouter(tags=["Pool"], prefix="/pools")
+
+            @pools_router.get(
+                "/{pool_name:path}",
+                dependencies=[Depends(requires_access_pool(method="GET"))],
+            )
+            def get_pool(pool_name: str): ...
+            """,
+        )
+        entries = extract_from_file(f)
+        assert len(entries) == 1
+        assert entries[0].full_path == "/api/v2/pools/{pool_name}"
+
     def test_syntax_error_raises_instead_of_dropping_entries(self, tmp_path):
         f = tmp_path / "broken.py"
         f.write_text("def broken(:\n")
@@ -647,6 +754,47 @@ class TestExtractAllPermissions:
         """
         assert len(all_entries) >= 100, f"Expected ≥100 entries, got 
{len(all_entries)}"
 
+    def test_every_declared_route_reaches_the_table(self, all_entries):
+        """
+        Each route decorator in the source must produce its own row.
+
+        This is the invariant that the ``path=`` bug broke: the eight routes in
+        ``backfills.py`` all resolved to ``/api/v2/backfills`` and deduplicated
+        down to three, without any warning.
+        """
+        verbs = {"GET", "POST", "PATCH", "PUT", "DELETE", "HEAD"}
+        declared: dict[str, int] = {}
+        for route_file in sorted(PUBLIC_ROUTES_DIR.glob("*.py")):
+            if route_file.name == "__init__.py":
+                continue
+            count = 0
+            for node in 
ast.walk(ast.parse(route_file.read_text(encoding="utf-8"))):
+                if not isinstance(node, (ast.FunctionDef, 
ast.AsyncFunctionDef)):
+                    continue
+                count += sum(
+                    1
+                    for deco in node.decorator_list
+                    if isinstance(deco, ast.Call)
+                    and isinstance(deco.func, ast.Attribute)
+                    and deco.func.attr.upper() in verbs
+                )
+            declared[route_file.name] = count
+
+        extracted: dict[str, set[tuple[str, str]]] = {}
+        for entry in all_entries:
+            extracted.setdefault(entry.source_file, 
set()).add((entry.full_path, entry.http_method))
+
+        for name, count in declared.items():
+            assert len(extracted.get(name, set())) == count, (
+                f"{name} declares {count} routes but the table holds "
+                f"{len(extracted.get(name, set()))} distinct operations"
+            )
+
+    def test_no_documented_path_contains_a_converter(self, all_entries):
+        """``{key:path}`` is Starlette matching syntax, not part of the URL 
clients call."""
+        offenders = [e.full_path for e in all_entries if 
re.search(r"\{[^{}]*:", e.full_path)]
+        assert offenders == []
+
     def test_output_is_sorted(self, all_entries):
         expected = sorted(
             all_entries,

Reply via email to