Repository: stac-utils/stac-fastapi
Pull request #705 (2024-06-10 10:08:07)

Allow default route dependencies
**Description:**
Allows for `*` to be used for the `path` or `method` variables in a route dependencies' scope to match all allowable routes.

**PR Checklist:**

- [x] `pre-commit` hooks pass locally
- [x] Tests pass (run `make test`)
- [x] Documentation has been updated to reflect changes, if applicable, and docs build successfully (run `make docs`)
- [x] Changes are added to the [CHANGELOG](https://github.com/stac-utils/stac-fastapi/blob/main/CHANGES.md).


vincentsarago: @alukach would you be able to review this one? 🙏 

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index d6499fb..e1a7e29 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -8,6 +8,7 @@
 
 ### Changed
 
+* Added option for default route dependencies `*` can be used for `path` or `method` to match all allowed route. ([#705](https://github.com/stac-utils/stac-fastapi/pull/705))
 * moved `AsyncBaseFiltersClient` and `BaseFiltersClient` classes in `stac_fastapi.extensions.core.filter.client` submodule ([#704](https://github.com/stac-utils/stac-fastapi/pull/704))
 
 ## [3.0.0a2] - 2024-05-31
diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py
index df4a136..bd6f4d9 100644
--- a/stac_fastapi/api/stac_fastapi/api/routes.py
+++ b/stac_fastapi/api/stac_fastapi/api/routes.py
@@ -1,5 +1,6 @@
 """Route factories."""
 
+import copy
 import functools
 import inspect
 import warnings
@@ -100,15 +101,28 @@ def add_route_dependencies(
     Allows a developer to add dependencies to a route after the route has been
     defined.
 
+    "*" can be used for path or method to match all allowed routes.
+
     Returns:
         None
     """
     for scope in scopes:
+        _scope = copy.deepcopy(scope)
         for route in routes:
-            match, _ = route.matches({"type": "http", **scope})
+            if scope["path"] == "*":
+                _scope["path"] = route.path
+
+            if scope["method"] == "*":
+                _scope["method"] = list(route.methods)[0]
+
+            match, _ = route.matches({"type": "http", **_scope})
             if match != Match.FULL:
                 continue
 
+            # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect
+            if not hasattr(route, "dependant"):
+                continue
+
             # Mimicking how APIRoute handles dependencies:
             # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412
             for depends in dependencies[::-1]:


Test patch:
diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py
index f2d51f1..deff0c0 100644
--- a/stac_fastapi/api/tests/test_api.py
+++ b/stac_fastapi/api/tests/test_api.py
@@ -49,6 +49,24 @@ class TestRouteDependencies:
                 ), "Authenticated requests should be accepted"
                 assert response.json() == "dummy response"
 
+    @staticmethod
+    def _assert_dependency_not_applied(api, routes):
+        with TestClient(api.app) as client:
+            for route in routes:
+                path = route["path"].format(
+                    collectionId="test_collection", itemId="test_item"
+                )
+                response = client.request(
+                    method=route["method"].lower(),
+                    url=path,
+                    content=route["payload"],
+                    headers={"content-type": "application/json"},
+                )
+                assert (
+                    200 <= response.status_code < 300
+                ), "Authenticated requests should be accepted"
+                assert response.json() == "dummy response"
+
     def test_openapi_content_type(self):
         api = self._build_api()
         with TestClient(api.app) as client:
@@ -116,6 +134,260 @@ class TestRouteDependencies:
         api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)])
         self._assert_dependency_applied(api, routes)
 
+    def test_build_api_with_default_route_dependencies(self, collection, item):
+        routes = [{"path": "*", "method": "*"}]
+        test_routes = [
+            {"path": "/collections", "method": "POST", "payload": collection},
+            {
+                "path": "/collections/{collectionId}",
+                "method": "PUT",
+                "payload": collection,
+            },
+            {"path": "/collections/{collectionId}", "method": "DELETE", "payload": ""},
+            {
+                "path": "/collections/{collectionId}/items",
+                "method": "POST",
+                "payload": item,
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "PUT",
+                "payload": item,
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+        ]
+        dependencies = [Depends(must_be_bob)]
+        api = self._build_api(route_dependencies=[(routes, dependencies)])
+        self._assert_dependency_applied(api, test_routes)
+
+    def test_build_api_with_default_path_route_dependencies(self, collection, item):
+        routes = [{"path": "*", "method": "POST"}]
+        test_routes = [
+            {
+                "path": "/collections",
+                "method": "POST",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}/items",
+                "method": "POST",
+                "payload": item,
+            },
+        ]
+        test_not_routes = [
+            {
+                "path": "/collections/{collectionId}",
+                "method": "PUT",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "PUT",
+                "payload": item,
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+        ]
+        dependencies = [Depends(must_be_bob)]
+        api = self._build_api(route_dependencies=[(routes, dependencies)])
+        self._assert_dependency_applied(api, test_routes)
+        self._assert_dependency_not_applied(api, test_not_routes)
+
+    def test_build_api_with_default_method_route_dependencies(self, collection, item):
+        routes = [
+            {
+                "path": "/collections/{collectionId}",
+                "method": "*",
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "*",
+            },
+        ]
+        test_routes = [
+            {
+                "path": "/collections/{collectionId}",
+                "method": "PUT",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "PUT",
+                "payload": item,
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+        ]
+        test_not_routes = [
+            {
+                "path": "/collections",
+                "method": "POST",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}/items",
+                "method": "POST",
+                "payload": item,
+            },
+        ]
+        dependencies = [Depends(must_be_bob)]
+        api = self._build_api(route_dependencies=[(routes, dependencies)])
+        self._assert_dependency_applied(api, test_routes)
+        self._assert_dependency_not_applied(api, test_not_routes)
+
+    def test_add_default_route_dependencies_after_building_api(self, collection, item):
+        routes = [{"path": "*", "method": "*"}]
+        test_routes = [
+            {
+                "path": "/collections",
+                "method": "POST",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}",
+                "method": "PUT",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+            {
+                "path": "/collections/{collectionId}/items",
+                "method": "POST",
+                "payload": item,
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "PUT",
+                "payload": item,
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+        ]
+        api = self._build_api()
+        api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)])
+        self._assert_dependency_applied(api, test_routes)
+
+    def test_add_default_path_route_dependencies_after_building_api(
+        self, collection, item
+    ):
+        routes = [{"path": "*", "method": "POST"}]
+        test_routes = [
+            {
+                "path": "/collections",
+                "method": "POST",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}/items",
+                "method": "POST",
+                "payload": item,
+            },
+        ]
+        test_not_routes = [
+            {
+                "path": "/collections/{collectionId}",
+                "method": "PUT",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "PUT",
+                "payload": item,
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+        ]
+        api = self._build_api()
+        api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)])
+        self._assert_dependency_applied(api, test_routes)
+        self._assert_dependency_not_applied(api, test_not_routes)
+
+    def test_add_default_method_route_dependencies_after_building_api(
+        self, collection, item
+    ):
+        routes = [
+            {
+                "path": "/collections/{collectionId}",
+                "method": "*",
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "*",
+            },
+        ]
+        test_routes = [
+            {
+                "path": "/collections/{collectionId}",
+                "method": "PUT",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "PUT",
+                "payload": item,
+            },
+            {
+                "path": "/collections/{collectionId}/items/{itemId}",
+                "method": "DELETE",
+                "payload": "",
+            },
+        ]
+        test_not_routes = [
+            {
+                "path": "/collections",
+                "method": "POST",
+                "payload": collection,
+            },
+            {
+                "path": "/collections/{collectionId}/items",
+                "method": "POST",
+                "payload": item,
+            },
+        ]
+        api = self._build_api()
+        api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)])
+        self._assert_dependency_applied(api, test_routes)
+        self._assert_dependency_not_applied(api, test_not_routes)
+
 
 class DummyCoreClient(core.BaseCoreClient):
     def all_collections(self, *args, **kwargs):