Skip to content

Commit 3c5cf69

Browse files
authored
feat: add configure_app for applying middleware to existing FastAPI applications (#85)
## Summary - add `configure_app` utility to wire routes and middleware onto an existing FastAPI instance - export `configure_app` and use it within `create_app`
1 parent 8ed08bc commit 3c5cf69

File tree

6 files changed

+208
-70
lines changed

6 files changed

+208
-70
lines changed

docs/user-guide/tips.md

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
## CORS
44

5-
The STAC Auth Proxy does not modify the [CORS response headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#the_http_response_headers) from the upstream STAC API. All CORS configuration must be handled by the upstream API.
5+
The STAC Auth Proxy does not modify the [CORS response headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#the_http_response_headers) from the upstream STAC API. All CORS configuration must be handled by the upstream API.
66

77
Because the STAC Auth Proxy introduces authentication, the upstream API’s CORS settings may need adjustment to support credentials. In most cases, this means:
88

9-
* [`Access-Control-Allow-Credentials`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) must be `true`
10-
* [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin) must _not_ be `*`[^CORSNotSupportingCredentials]
9+
- [`Access-Control-Allow-Credentials`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) must be `true`
10+
- [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin) must _not_ be `*`[^CORSNotSupportingCredentials]
1111

1212
[^CORSNotSupportingCredentials]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors/CORSNotSupportingCredentials
1313

@@ -32,13 +32,40 @@ Rather than performing the login flow, the Swagger UI can be configured to accep
3232
```sh
3333
OPENAPI_AUTH_SCHEME_NAME=jwtAuth
3434
OPENAPI_AUTH_SCHEME_OVERRIDE='{
35-
"type": "http",
36-
"scheme": "bearer",
37-
"bearerFormat": "JWT",
35+
"type": "http",
36+
"scheme": "bearer",
37+
"bearerFormat": "JWT",
3838
"description": "Paste your raw JWT here. This API uses Bearer token authorization."
3939
}'
4040
```
4141

42-
## Runtime Customization
42+
## Non-proxy Configuration
4343

44-
While the project is designed to work out-of-the-box as an application, it might not address every projects needs. When the need for customization arises, the codebase can instead be treated as a library of components that can be used to augment any [ASGI](https://asgi.readthedocs.io/en/latest/)-compliant webserver (e.g. [Django](https://docs.djangoproject.com/en/3.0/topics/async/), [Falcon](https://falconframework.org/), [FastAPI](https://github.com/tiangolo/fastapi), [Litestar](https://litestar.dev/), [Responder](https://responder.readthedocs.io/en/latest/), [Sanic](https://sanic.dev/), [Starlette](https://www.starlette.io/)). Review [`app.py`](https://github.com/developmentseed/stac-auth-proxy/blob/main/src/stac_auth_proxy/app.py) to get a sense of how we make use of the various components to construct a FastAPI application.
44+
While the project is designed to work out-of-the-box as an application, it might not address every projects needs. When the need for customization arises, the codebase can instead be treated as a library of components that can be used to augment a FastAPI server. This may look something like the following:
45+
46+
```py
47+
from fastapi import FastAPI
48+
from stac_fastapi.api.app import StacApi
49+
from stac_auth_proxy import build_lifespan, configure_app, Settings as StacAuthSettings
50+
51+
# Create Auth Settings
52+
auth_settings = StacAuthSettings(
53+
upstream_url='https://stac-server',
54+
oidc_discovery_url='https://auth-server/.well-known/openid-configuration',
55+
)
56+
57+
# Setup App
58+
app = FastAPI(
59+
...
60+
lifespan=build_lifespan(auth_settings),
61+
)
62+
63+
# Apply STAC Auth Proxy middleware
64+
configure_app(app, auth_settings)
65+
66+
# Setup STAC API
67+
api = StacApi(
68+
app,
69+
...
70+
)
71+
```

src/stac_auth_proxy/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
with some internal STAC API.
77
"""
88

9-
from .app import create_app
9+
from .app import configure_app, create_app
1010
from .config import Settings
11+
from .lifespan import build_lifespan
1112

12-
__all__ = ["create_app", "Settings"]
13+
__all__ = [
14+
"build_lifespan",
15+
"create_app",
16+
"configure_app",
17+
"Settings",
18+
]

src/stac_auth_proxy/app.py

Lines changed: 49 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
"""
77

88
import logging
9-
from contextlib import asynccontextmanager
10-
from typing import Optional
9+
from typing import Any, Optional
1110

1211
from fastapi import FastAPI
1312
from starlette_cramjam.middleware import CompressionMiddleware
1413

1514
from .config import Settings
1615
from .handlers import HealthzHandler, ReverseProxyHandler, SwaggerUI
16+
from .lifespan import build_lifespan
1717
from .middleware import (
1818
AddProcessTimeHeaderMiddleware,
1919
AuthenticationExtensionMiddleware,
@@ -26,58 +26,33 @@
2626
ProcessLinksMiddleware,
2727
RemoveRootPathMiddleware,
2828
)
29-
from .utils.lifespan import check_conformance, check_server_health
3029

3130
logger = logging.getLogger(__name__)
3231

3332

34-
def create_app(settings: Optional[Settings] = None) -> FastAPI:
35-
"""FastAPI Application Factory."""
36-
settings = settings or Settings()
33+
def configure_app(
34+
app: FastAPI,
35+
settings: Optional[Settings] = None,
36+
**settings_kwargs: Any,
37+
) -> FastAPI:
38+
"""
39+
Apply routes and middleware to a FastAPI app.
40+
41+
Parameters
42+
----------
43+
app : FastAPI
44+
The FastAPI app to configure.
45+
settings : Settings | None, optional
46+
Pre-built settings instance. If omitted, a new one is constructed from
47+
``settings_kwargs``.
48+
**settings_kwargs : Any
49+
Keyword arguments used to configure the health and conformance checks if
50+
``settings`` is not provided.
51+
"""
52+
settings = settings or Settings(**settings_kwargs)
3753

3854
#
39-
# Application
40-
#
41-
42-
@asynccontextmanager
43-
async def lifespan(app: FastAPI):
44-
assert settings
45-
46-
# Wait for upstream servers to become available
47-
if settings.wait_for_upstream:
48-
logger.info("Running upstream server health checks...")
49-
urls = [settings.upstream_url, settings.oidc_discovery_internal_url]
50-
for url in urls:
51-
await check_server_health(url=url)
52-
logger.info(
53-
"Upstream servers are healthy:\n%s",
54-
"\n".join([f" - {url}" for url in urls]),
55-
)
56-
57-
# Log all middleware connected to the app
58-
logger.info(
59-
"Connected middleware:\n%s",
60-
"\n".join([f" - {m.cls.__name__}" for m in app.user_middleware]),
61-
)
62-
63-
if settings.check_conformance:
64-
await check_conformance(
65-
app.user_middleware,
66-
str(settings.upstream_url),
67-
)
68-
69-
yield
70-
71-
app = FastAPI(
72-
openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema
73-
lifespan=lifespan,
74-
root_path=settings.root_path,
75-
)
76-
if app.root_path:
77-
logger.debug("Mounted app at %s", app.root_path)
78-
79-
#
80-
# Handlers (place catch-all proxy handler last)
55+
# Route Handlers
8156
#
8257

8358
# If we have customized Swagger UI Init settings (e.g. a provided client_id)
@@ -105,15 +80,6 @@ async def lifespan(app: FastAPI):
10580
prefix=settings.healthz_prefix,
10681
)
10782

108-
app.add_api_route(
109-
"/{path:path}",
110-
ReverseProxyHandler(
111-
upstream=str(settings.upstream_url),
112-
override_host=settings.override_host,
113-
).proxy_request,
114-
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
115-
)
116-
11783
#
11884
# Middleware (order is important, last added = first to run)
11985
#
@@ -186,3 +152,29 @@ async def lifespan(app: FastAPI):
186152
)
187153

188154
return app
155+
156+
157+
def create_app(settings: Optional[Settings] = None) -> FastAPI:
158+
"""FastAPI Application Factory."""
159+
settings = settings or Settings()
160+
161+
app = FastAPI(
162+
openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema
163+
lifespan=build_lifespan(settings=settings),
164+
root_path=settings.root_path,
165+
)
166+
if app.root_path:
167+
logger.debug("Mounted app at %s", app.root_path)
168+
169+
configure_app(app, settings)
170+
171+
app.add_api_route(
172+
"/{path:path}",
173+
ReverseProxyHandler(
174+
upstream=str(settings.upstream_url),
175+
override_host=settings.override_host,
176+
).proxy_request,
177+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
178+
)
179+
180+
return app

src/stac_auth_proxy/utils/lifespan.py renamed to src/stac_auth_proxy/lifespan.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
1-
"""Health check implementations for lifespan events."""
1+
"""Reusable lifespan handler for FastAPI applications."""
22

33
import asyncio
44
import logging
55
import re
6+
from contextlib import asynccontextmanager
7+
from typing import Any
68

79
import httpx
10+
from fastapi import FastAPI
811
from pydantic import HttpUrl
912
from starlette.middleware import Middleware
1013

14+
from .config import Settings
15+
1116
logger = logging.getLogger(__name__)
17+
__all__ = ["build_lifespan", "check_conformance", "check_server_health"]
18+
19+
20+
async def check_server_healths(*urls: str | HttpUrl) -> None:
21+
"""Wait for upstream APIs to become available."""
22+
logger.info("Running upstream server health checks...")
23+
for url in urls:
24+
await check_server_health(url)
25+
logger.info(
26+
"Upstream servers are healthy:\n%s",
27+
"\n".join([f" - {url}" for url in urls]),
28+
)
1229

1330

1431
async def check_server_health(
@@ -91,3 +108,48 @@ def conformance_str(conformance: str) -> str:
91108
"Upstream catalog conforms to the following required conformance classes: \n%s",
92109
"\n".join([conformance_str(c) for c in required_conformances]),
93110
)
111+
112+
113+
def build_lifespan(settings: Settings | None = None, **settings_kwargs: Any):
114+
"""
115+
Create a lifespan handler that runs startup checks.
116+
117+
Parameters
118+
----------
119+
settings : Settings | None, optional
120+
Pre-built settings instance. If omitted, a new one is constructed from
121+
``settings_kwargs``.
122+
**settings_kwargs : Any
123+
Keyword arguments used to configure the health and conformance checks if
124+
``settings`` is not provided.
125+
126+
Returns
127+
-------
128+
Callable[[FastAPI], AsyncContextManager[Any]]
129+
A callable suitable for the ``lifespan`` parameter of ``FastAPI``.
130+
"""
131+
if settings is None:
132+
settings = Settings(**settings_kwargs)
133+
134+
@asynccontextmanager
135+
async def lifespan(app: "FastAPI"):
136+
assert settings is not None # Required for type checking
137+
138+
# Wait for upstream servers to become available
139+
if settings.wait_for_upstream:
140+
await check_server_healths(
141+
settings.upstream_url, settings.oidc_discovery_internal_url
142+
)
143+
144+
# Log all middleware connected to the app
145+
logger.info(
146+
"Connected middleware:\n%s",
147+
"\n".join([f" - {m.cls.__name__}" for m in app.user_middleware]),
148+
)
149+
150+
if settings.check_conformance:
151+
await check_conformance(app.user_middleware, str(settings.upstream_url))
152+
153+
yield
154+
155+
return lifespan

tests/test_configure_app.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Tests for configuring an external FastAPI application."""
2+
3+
from fastapi import FastAPI
4+
from fastapi.routing import APIRoute
5+
6+
from stac_auth_proxy import Settings, configure_app
7+
8+
9+
def test_configure_app_excludes_proxy_route():
10+
"""Ensure `configure_app` adds health route and omits proxy route."""
11+
app = FastAPI()
12+
settings = Settings(
13+
upstream_url="https://example.com",
14+
oidc_discovery_url="https://example.com/.well-known/openid-configuration",
15+
wait_for_upstream=False,
16+
check_conformance=False,
17+
default_public=True,
18+
)
19+
20+
configure_app(app, settings)
21+
22+
routes = [r.path for r in app.router.routes if isinstance(r, APIRoute)]
23+
assert settings.healthz_prefix in routes
24+
assert "/{path:path}" not in routes

tests/test_lifespan.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Tests for lifespan module."""
22

33
from dataclasses import dataclass
4-
from unittest.mock import patch
4+
from unittest.mock import AsyncMock, patch
55

66
import pytest
7+
from fastapi import FastAPI
8+
from fastapi.testclient import TestClient
79
from starlette.middleware import Middleware
810
from starlette.types import ASGIApp
911

10-
from stac_auth_proxy.utils.lifespan import check_conformance, check_server_health
12+
from stac_auth_proxy import build_lifespan
13+
from stac_auth_proxy.lifespan import check_conformance, check_server_health
1114
from stac_auth_proxy.utils.middleware import required_conformance
1215

1316

@@ -80,3 +83,27 @@ def __init__(self, app):
8083

8184
middleware = [Middleware(NoConformanceMiddleware)]
8285
await check_conformance(middleware, source_api_server)
86+
87+
88+
def test_lifespan_reusable():
89+
"""Ensure the public lifespan handler runs health and conformance checks."""
90+
upstream_url = "https://example.com"
91+
oidc_discovery_url = "https://example.com/.well-known/openid-configuration"
92+
with patch(
93+
"stac_auth_proxy.lifespan.check_server_health",
94+
new=AsyncMock(),
95+
) as mock_health, patch(
96+
"stac_auth_proxy.lifespan.check_conformance",
97+
new=AsyncMock(),
98+
) as mock_conf:
99+
app = FastAPI(
100+
lifespan=build_lifespan(
101+
upstream_url=upstream_url,
102+
oidc_discovery_url=oidc_discovery_url,
103+
)
104+
)
105+
with TestClient(app):
106+
pass
107+
assert mock_health.await_count == 2
108+
expected_upstream = upstream_url.rstrip("/") + "/"
109+
mock_conf.assert_awaited_once_with(app.user_middleware, expected_upstream)

0 commit comments

Comments
 (0)