Skip to content

Commit 1d58d23

Browse files
Convenience method to allow customizing route dependencies (#295)
* Support customizing route dependencies * Refactor to support direct method calls * Lint fixup * Reorg for legibility, code organization * Lint fix * Lint fix * Add missing import * Use app router * Lint fix * Ensure dependencies persist if attached to router * Cleanup comments * Add tests for stac_api core * Cleanup * Apply isort * Add to changelog * Add docstring * Format code Co-authored-by: Vincent Sarago <[email protected]>
1 parent 959c64f commit 1d58d23

File tree

5 files changed

+220
-12
lines changed

5 files changed

+220
-12
lines changed

.github/workflows/cicd.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ jobs:
7777
POSTGRES_HOST: localhost
7878
POSTGRES_PORT: 5432
7979

80+
- name: Run test suite
81+
run: |
82+
cd stac_fastapi/api && pipenv run pytest -svvv
83+
env:
84+
ENVIRONMENT: testing
85+
8086
- name: Run test suite
8187
run: |
8288
cd stac_fastapi/sqlalchemy && pipenv run pytest -svvv

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Added
66

7+
* Add hook to allow adding dependencies to routes. ([#295](https://github.com/stac-utils/stac-fastapi/pull/295))
8+
79
### Changed
810

911
* update FastAPI requirement to allow version >=0.73 ([#337](https://github.com/stac-utils/stac-fastapi/pull/337))

stac_fastapi/api/stac_fastapi/api/app.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""fastapi app creation."""
2-
from typing import Any, Callable, Dict, List, Optional, Type, Union
2+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
33

44
import attr
55
from brotli_asgi import BrotliMiddleware
66
from fastapi import APIRouter, FastAPI
77
from fastapi.openapi.utils import get_openapi
8+
from fastapi.params import Depends
89
from pydantic import BaseModel
910
from stac_pydantic import Collection, Item, ItemCollection
1011
from stac_pydantic.api import ConformanceClasses, LandingPage
@@ -23,7 +24,12 @@
2324
create_request_model,
2425
)
2526
from stac_fastapi.api.openapi import update_openapi
26-
from stac_fastapi.api.routes import create_async_endpoint, create_sync_endpoint
27+
from stac_fastapi.api.routes import (
28+
Scope,
29+
add_route_dependencies,
30+
create_async_endpoint,
31+
create_sync_endpoint,
32+
)
2733

2834
# TODO: make this module not depend on `stac_fastapi.extensions`
2935
from stac_fastapi.extensions.core import FieldsExtension, TokenPaginationExtension
@@ -42,19 +48,17 @@ class StacApi:
4248
4349
Attributes:
4450
settings:
45-
API settings and configuration, potentially using environment variables.
46-
See https://pydantic-docs.helpmanual.io/usage/settings/.
51+
API settings and configuration, potentially using environment variables. See https://pydantic-docs.helpmanual.io/usage/settings/.
4752
client:
48-
A subclass of `stac_api.clients.BaseCoreClient`. Defines the application logic which is injected
49-
into the API.
53+
A subclass of `stac_api.clients.BaseCoreClient`. Defines the application logic which is injected into the API.
5054
extensions:
51-
API extensions to include with the application. This may include official STAC extensions as well as
52-
third-party add ons.
55+
API extensions to include with the application. This may include official STAC extensions as well as third-party add ons.
5356
exceptions:
54-
Defines a global mapping between exceptions and status codes, allowing configuration of response behavior on
55-
certain exceptions (https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers).
57+
Defines a global mapping between exceptions and status codes, allowing configuration of response behavior on certain exceptions (https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers).
5658
app:
5759
The FastAPI application, defaults to a fresh application.
60+
route_dependencies (list of tuples of route scope dicts (eg `{'path': '/collections', 'method': 'POST'}`) and list of dependencies (e.g. `[Depends(oauth2_scheme)]`)):
61+
Applies specified dependencies to specified routes. This is useful for applying custom auth requirements to routes defined elsewhere in the application.
5862
"""
5963

6064
settings: ApiSettings = attr.ib()
@@ -88,6 +92,7 @@ class StacApi:
8892
pagination_extension = attr.ib(default=TokenPaginationExtension)
8993
response_class: Type[Response] = attr.ib(default=JSONResponse)
9094
middlewares: List = attr.ib(default=attr.Factory(lambda: [BrotliMiddleware]))
95+
route_dependencies: List[Tuple[List[Scope], List[Depends]]] = attr.ib(default=[])
9196

9297
def get_extension(self, extension: Type[ApiExtension]) -> Optional[ApiExtension]:
9398
"""Get an extension.
@@ -337,6 +342,20 @@ async def ping():
337342

338343
self.app.include_router(mgmt_router, tags=["Liveliness/Readiness"])
339344

345+
def add_route_dependencies(
346+
self, scopes: List[Scope], dependencies=List[Depends]
347+
) -> None:
348+
"""Add custom dependencies to routes.
349+
350+
Args:
351+
scopes: list of scopes. Each scope should be a dict with a `path` and `method` property.
352+
dependencies: list of [FastAPI dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/) to apply to each scope.
353+
354+
Returns:
355+
None
356+
"""
357+
return add_route_dependencies(self.app.router.routes, scopes, dependencies)
358+
340359
def __attrs_post_init__(self):
341360
"""Post-init hook.
342361
@@ -378,3 +397,7 @@ def __attrs_post_init__(self):
378397
# add middlewares
379398
for middleware in self.middlewares:
380399
self.app.add_middleware(middleware)
400+
401+
# customize route dependencies
402+
for scopes, dependencies in self.route_dependencies:
403+
self.add_route_dependencies(scopes=scopes, dependencies=dependencies)

stac_fastapi/api/stac_fastapi/api/routes.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""route factories."""
2-
from typing import Any, Callable, Dict, Type, Union
2+
from typing import Any, Callable, Dict, List, Optional, Type, TypedDict, Union
33

4-
from fastapi import Depends
4+
from fastapi import Depends, params
5+
from fastapi.dependencies.utils import get_parameterless_sub_dependant
56
from pydantic import BaseModel
67
from starlette.requests import Request
78
from starlette.responses import JSONResponse, Response
9+
from starlette.routing import BaseRoute, Match
810

911
from stac_fastapi.api.models import APIRequest
1012

@@ -94,3 +96,47 @@ def _endpoint(
9496
return _wrap_response(func(request_data, request=request), response_class)
9597

9698
return _endpoint
99+
100+
101+
class Scope(TypedDict, total=False):
102+
"""More strict version of Starlette's Scope."""
103+
104+
# https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3
105+
path: str
106+
method: str
107+
type: Optional[str]
108+
109+
110+
def add_route_dependencies(
111+
routes: List[BaseRoute], scopes: List[Scope], dependencies=List[params.Depends]
112+
) -> None:
113+
"""Add dependencies to routes.
114+
115+
Allows a developer to add dependencies to a route after the route has been
116+
defined.
117+
118+
Returns:
119+
None
120+
"""
121+
for scope in scopes:
122+
for route in routes:
123+
124+
match, _ = route.matches({"type": "http", **scope})
125+
if match != Match.FULL:
126+
continue
127+
128+
# Mimicking how APIRoute handles dependencies:
129+
# https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412
130+
for depends in dependencies[::-1]:
131+
route.dependant.dependencies.insert(
132+
0,
133+
get_parameterless_sub_dependant(
134+
depends=depends, path=route.path_format
135+
),
136+
)
137+
138+
# Register dependencies directly on route so that they aren't ignored if
139+
# the routes are later associated with an app (e.g. app.include_router(router))
140+
# https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360
141+
# https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678
142+
route.dependencies.extend(dependencies)

stac_fastapi/api/tests/test_api.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from fastapi import Depends, HTTPException, security, status
2+
from starlette.testclient import TestClient
3+
4+
from stac_fastapi.api.app import StacApi
5+
from stac_fastapi.extensions.core import TokenPaginationExtension, TransactionExtension
6+
from stac_fastapi.types import config, core
7+
8+
9+
class TestRouteDependencies:
10+
@staticmethod
11+
def _build_api(**overrides):
12+
settings = config.ApiSettings()
13+
return StacApi(
14+
**{
15+
"settings": settings,
16+
"client": DummyCoreClient(),
17+
"extensions": [
18+
TransactionExtension(
19+
client=DummyTransactionsClient(), settings=settings
20+
),
21+
TokenPaginationExtension(),
22+
],
23+
**overrides,
24+
}
25+
)
26+
27+
@staticmethod
28+
def _assert_dependency_applied(api, routes):
29+
with TestClient(api.app) as client:
30+
for route in routes:
31+
response = getattr(client, route["method"].lower())(route["path"])
32+
assert (
33+
response.status_code == 401
34+
), "Unauthenticated requests should be rejected"
35+
assert response.json() == {"detail": "Not authenticated"}
36+
37+
make_request = getattr(client, route["method"].lower())
38+
path = route["path"].format(
39+
collectionId="test_collection", itemId="test_item"
40+
)
41+
response = make_request(
42+
path,
43+
auth=("bob", "dobbs"),
44+
data='{"dummy": "payload"}',
45+
headers={"content-type": "application/json"},
46+
)
47+
assert (
48+
response.status_code == 200
49+
), "Authenticated requests should be accepted"
50+
assert response.json() == "dummy response"
51+
52+
def test_build_api_with_route_dependencies(self):
53+
routes = [
54+
{"path": "/collections", "method": "POST"},
55+
{"path": "/collections", "method": "PUT"},
56+
{"path": "/collections/{collectionId}", "method": "DELETE"},
57+
{"path": "/collections/{collectionId}/items", "method": "POST"},
58+
{"path": "/collections/{collectionId}/items", "method": "PUT"},
59+
{"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"},
60+
]
61+
dependencies = [Depends(must_be_bob)]
62+
api = self._build_api(route_dependencies=[(routes, dependencies)])
63+
self._assert_dependency_applied(api, routes)
64+
65+
def test_add_route_dependencies_after_building_api(self):
66+
routes = [
67+
{"path": "/collections", "method": "POST"},
68+
{"path": "/collections", "method": "PUT"},
69+
{"path": "/collections/{collectionId}", "method": "DELETE"},
70+
{"path": "/collections/{collectionId}/items", "method": "POST"},
71+
{"path": "/collections/{collectionId}/items", "method": "PUT"},
72+
{"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"},
73+
]
74+
api = self._build_api()
75+
api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)])
76+
self._assert_dependency_applied(api, routes)
77+
78+
79+
class DummyCoreClient(core.BaseCoreClient):
80+
def all_collections(self, *args, **kwargs):
81+
...
82+
83+
def get_collection(self, *args, **kwargs):
84+
...
85+
86+
def get_item(self, *args, **kwargs):
87+
...
88+
89+
def get_search(self, *args, **kwargs):
90+
...
91+
92+
def post_search(self, *args, **kwargs):
93+
...
94+
95+
def item_collection(self, *args, **kwargs):
96+
...
97+
98+
99+
class DummyTransactionsClient(core.BaseTransactionsClient):
100+
"""Defines a pattern for implementing the STAC transaction extension."""
101+
102+
def create_item(self, *args, **kwargs):
103+
return "dummy response"
104+
105+
def update_item(self, *args, **kwargs):
106+
return "dummy response"
107+
108+
def delete_item(self, *args, **kwargs):
109+
return "dummy response"
110+
111+
def create_collection(self, *args, **kwargs):
112+
return "dummy response"
113+
114+
def update_collection(self, *args, **kwargs):
115+
return "dummy response"
116+
117+
def delete_collection(self, *args, **kwargs):
118+
return "dummy response"
119+
120+
121+
def must_be_bob(
122+
credentials: security.HTTPBasicCredentials = Depends(security.HTTPBasic()),
123+
):
124+
if credentials.username == "bob":
125+
return True
126+
127+
raise HTTPException(
128+
status_code=status.HTTP_401_UNAUTHORIZED,
129+
detail="You're not Bob",
130+
headers={"WWW-Authenticate": "Basic"},
131+
)

0 commit comments

Comments
 (0)