From 52ed04569f45de9f9f2a3a6f8341b0e8413ef5c5 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 11 Aug 2025 21:35:41 -0700 Subject: [PATCH 01/10] refactor: allow configuring external FastAPI app --- src/stac_auth_proxy/__init__.py | 4 +- src/stac_auth_proxy/app.py | 113 +++++++++++++++++--------------- tests/test_configure_app.py | 22 +++++++ 3 files changed, 85 insertions(+), 54 deletions(-) create mode 100644 tests/test_configure_app.py diff --git a/src/stac_auth_proxy/__init__.py b/src/stac_auth_proxy/__init__.py index 35eaf7b7..408300c8 100644 --- a/src/stac_auth_proxy/__init__.py +++ b/src/stac_auth_proxy/__init__.py @@ -6,7 +6,7 @@ with some internal STAC API. """ -from .app import create_app +from .app import configure_app, create_app from .config import Settings -__all__ = ["create_app", "Settings"] +__all__ = ["create_app", "configure_app", "Settings"] diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index b497c0db..a210b08e 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -31,51 +31,10 @@ logger = logging.getLogger(__name__) -def create_app(settings: Optional[Settings] = None) -> FastAPI: - """FastAPI Application Factory.""" +def configure_app(app: FastAPI, settings: Optional[Settings] = None) -> FastAPI: + """Apply routes and middleware to an existing FastAPI app.""" settings = settings or Settings() - # - # Application - # - - @asynccontextmanager - async def lifespan(app: FastAPI): - assert settings - - # Wait for upstream servers to become available - if settings.wait_for_upstream: - logger.info("Running upstream server health checks...") - urls = [settings.upstream_url, settings.oidc_discovery_internal_url] - for url in urls: - await check_server_health(url=url) - logger.info( - "Upstream servers are healthy:\n%s", - "\n".join([f" - {url}" for url in urls]), - ) - - # Log all middleware connected to the app - logger.info( - "Connected middleware:\n%s", - "\n".join([f" - {m.cls.__name__}" for m in app.user_middleware]), - ) - - if settings.check_conformance: - await check_conformance( - app.user_middleware, - str(settings.upstream_url), - ) - - yield - - app = FastAPI( - openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema - lifespan=lifespan, - root_path=settings.root_path, - ) - if app.root_path: - logger.debug("Mounted app at %s", app.root_path) - # # Handlers (place catch-all proxy handler last) # @@ -105,15 +64,6 @@ async def lifespan(app: FastAPI): prefix=settings.healthz_prefix, ) - app.add_api_route( - "/{path:path}", - ReverseProxyHandler( - upstream=str(settings.upstream_url), - override_host=settings.override_host, - ).proxy_request, - methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - ) - # # Middleware (order is important, last added = first to run) # @@ -186,3 +136,62 @@ async def lifespan(app: FastAPI): ) return app + + +def create_app(settings: Optional[Settings] = None) -> FastAPI: + """FastAPI Application Factory.""" + settings = settings or Settings() + + # + # Application + # + + @asynccontextmanager + async def lifespan(app: FastAPI): + assert settings + + # Wait for upstream servers to become available + if settings.wait_for_upstream: + logger.info("Running upstream server health checks...") + urls = [settings.upstream_url, settings.oidc_discovery_internal_url] + for url in urls: + await check_server_health(url=url) + logger.info( + "Upstream servers are healthy:\n%s", + "\n".join([f" - {url}" for url in urls]), + ) + + # Log all middleware connected to the app + logger.info( + "Connected middleware:\n%s", + "\n".join([f" - {m.cls.__name__}" for m in app.user_middleware]), + ) + + if settings.check_conformance: + await check_conformance( + app.user_middleware, + str(settings.upstream_url), + ) + + yield + + app = FastAPI( + openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema + lifespan=lifespan, + root_path=settings.root_path, + ) + if app.root_path: + logger.debug("Mounted app at %s", app.root_path) + + configure_app(app, settings) + + app.add_api_route( + "/{path:path}", + ReverseProxyHandler( + upstream=str(settings.upstream_url), + override_host=settings.override_host, + ).proxy_request, + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + ) + + return app diff --git a/tests/test_configure_app.py b/tests/test_configure_app.py new file mode 100644 index 00000000..d517ff08 --- /dev/null +++ b/tests/test_configure_app.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI +from fastapi.routing import APIRoute + +from stac_auth_proxy import Settings +from stac_auth_proxy.app import configure_app + + +def test_configure_app_excludes_proxy_route(): + app = FastAPI() + settings = Settings( + upstream_url="https://example.com", + oidc_discovery_url="https://example.com/.well-known/openid-configuration", + wait_for_upstream=False, + check_conformance=False, + default_public=True, + ) + + configure_app(app, settings) + + routes = [r.path for r in app.router.routes if isinstance(r, APIRoute)] + assert settings.healthz_prefix in routes + assert "/{path:path}" not in routes From 402a2d368993575a7afc408c0a864f89f206347b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 11 Aug 2025 22:12:27 -0700 Subject: [PATCH 02/10] test: document configure_app test --- tests/test_configure_app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_configure_app.py b/tests/test_configure_app.py index d517ff08..df484003 100644 --- a/tests/test_configure_app.py +++ b/tests/test_configure_app.py @@ -1,3 +1,5 @@ +"""Tests for configuring an external FastAPI application.""" + from fastapi import FastAPI from fastapi.routing import APIRoute @@ -6,6 +8,7 @@ def test_configure_app_excludes_proxy_route(): + """Ensure `configure_app` adds health route and omits proxy route.""" app = FastAPI() settings = Settings( upstream_url="https://example.com", From 301b39c38f9f820792c132331dd3b17bd08bb6cc Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 12 Aug 2025 21:23:10 -0700 Subject: [PATCH 03/10] refactor: expose lifespan checks --- src/stac_auth_proxy/__init__.py | 9 ++++++++- src/stac_auth_proxy/lifespan.py | 10 ++++++++++ tests/test_configure_app.py | 3 +-- tests/test_lifespan.py | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 src/stac_auth_proxy/lifespan.py diff --git a/src/stac_auth_proxy/__init__.py b/src/stac_auth_proxy/__init__.py index 408300c8..a00e88f2 100644 --- a/src/stac_auth_proxy/__init__.py +++ b/src/stac_auth_proxy/__init__.py @@ -8,5 +8,12 @@ from .app import configure_app, create_app from .config import Settings +from .lifespan import check_conformance, check_server_health -__all__ = ["create_app", "configure_app", "Settings"] +__all__ = [ + "create_app", + "configure_app", + "check_conformance", + "check_server_health", + "Settings", +] diff --git a/src/stac_auth_proxy/lifespan.py b/src/stac_auth_proxy/lifespan.py new file mode 100644 index 00000000..85df21ae --- /dev/null +++ b/src/stac_auth_proxy/lifespan.py @@ -0,0 +1,10 @@ +"""Public access to lifespan health checks. + +This module re-exports the ``check_server_health`` and ``check_conformance`` +utilities so that library users can import them without reaching into the +internal ``utils`` package. +""" + +from .utils.lifespan import check_conformance, check_server_health + +__all__ = ["check_server_health", "check_conformance"] diff --git a/tests/test_configure_app.py b/tests/test_configure_app.py index df484003..71959f9b 100644 --- a/tests/test_configure_app.py +++ b/tests/test_configure_app.py @@ -3,8 +3,7 @@ from fastapi import FastAPI from fastapi.routing import APIRoute -from stac_auth_proxy import Settings -from stac_auth_proxy.app import configure_app +from stac_auth_proxy import Settings, configure_app def test_configure_app_excludes_proxy_route(): diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index b0396019..5f6ab927 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -7,7 +7,7 @@ from starlette.middleware import Middleware from starlette.types import ASGIApp -from stac_auth_proxy.utils.lifespan import check_conformance, check_server_health +from stac_auth_proxy import check_conformance, check_server_health from stac_auth_proxy.utils.middleware import required_conformance From d4b30c7f3423af86864ca0b63d3ddb1c07606919 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 12 Aug 2025 22:24:39 -0700 Subject: [PATCH 04/10] refactor: expose reusable lifespan handler --- src/stac_auth_proxy/__init__.py | 5 ++- src/stac_auth_proxy/app.py | 40 ++-------------------- src/stac_auth_proxy/lifespan.py | 60 +++++++++++++++++++++++++++++---- tests/test_lifespan.py | 29 ++++++++++++++-- 4 files changed, 85 insertions(+), 49 deletions(-) diff --git a/src/stac_auth_proxy/__init__.py b/src/stac_auth_proxy/__init__.py index a00e88f2..5c4c04d7 100644 --- a/src/stac_auth_proxy/__init__.py +++ b/src/stac_auth_proxy/__init__.py @@ -8,12 +8,11 @@ from .app import configure_app, create_app from .config import Settings -from .lifespan import check_conformance, check_server_health +from .lifespan import lifespan __all__ = [ "create_app", "configure_app", - "check_conformance", - "check_server_health", + "lifespan", "Settings", ] diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index a210b08e..8684483b 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -6,7 +6,6 @@ """ import logging -from contextlib import asynccontextmanager from typing import Optional from fastapi import FastAPI @@ -26,7 +25,7 @@ ProcessLinksMiddleware, RemoveRootPathMiddleware, ) -from .utils.lifespan import check_conformance, check_server_health +from .lifespan import lifespan logger = logging.getLogger(__name__) @@ -136,48 +135,13 @@ def configure_app(app: FastAPI, settings: Optional[Settings] = None) -> FastAPI: ) return app - - def create_app(settings: Optional[Settings] = None) -> FastAPI: """FastAPI Application Factory.""" settings = settings or Settings() - # - # Application - # - - @asynccontextmanager - async def lifespan(app: FastAPI): - assert settings - - # Wait for upstream servers to become available - if settings.wait_for_upstream: - logger.info("Running upstream server health checks...") - urls = [settings.upstream_url, settings.oidc_discovery_internal_url] - for url in urls: - await check_server_health(url=url) - logger.info( - "Upstream servers are healthy:\n%s", - "\n".join([f" - {url}" for url in urls]), - ) - - # Log all middleware connected to the app - logger.info( - "Connected middleware:\n%s", - "\n".join([f" - {m.cls.__name__}" for m in app.user_middleware]), - ) - - if settings.check_conformance: - await check_conformance( - app.user_middleware, - str(settings.upstream_url), - ) - - yield - app = FastAPI( openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema - lifespan=lifespan, + lifespan=lifespan(settings), root_path=settings.root_path, ) if app.root_path: diff --git a/src/stac_auth_proxy/lifespan.py b/src/stac_auth_proxy/lifespan.py index 85df21ae..e2d86a86 100644 --- a/src/stac_auth_proxy/lifespan.py +++ b/src/stac_auth_proxy/lifespan.py @@ -1,10 +1,58 @@ -"""Public access to lifespan health checks. +"""Reusable lifespan handler for FastAPI applications.""" -This module re-exports the ``check_server_health`` and ``check_conformance`` -utilities so that library users can import them without reaching into the -internal ``utils`` package. -""" +from contextlib import asynccontextmanager +import logging +from fastapi import FastAPI +from .config import Settings from .utils.lifespan import check_conformance, check_server_health -__all__ = ["check_server_health", "check_conformance"] +logger = logging.getLogger(__name__) + + +def lifespan(settings: Settings | None = None): + """Create a lifespan handler that runs startup checks. + + Parameters + ---------- + settings : Settings | None + Configuration for health and conformance checks. If omitted, default + settings are loaded. + + Returns + ------- + Callable[[FastAPI], AsyncContextManager[Any]] + A callable suitable for the ``lifespan`` parameter of ``FastAPI``. + """ + + settings = settings or Settings() + + @asynccontextmanager + async def _lifespan(app: FastAPI): + # Wait for upstream servers to become available + if settings.wait_for_upstream: + logger.info("Running upstream server health checks...") + urls = [settings.upstream_url, settings.oidc_discovery_internal_url] + for url in urls: + await check_server_health(url=url) + logger.info( + "Upstream servers are healthy:\n%s", + "\n".join([f" - {url}" for url in urls]), + ) + + # Log all middleware connected to the app + logger.info( + "Connected middleware:\n%s", + "\n".join([f" - {m.cls.__name__}" for m in app.user_middleware]), + ) + + if settings.check_conformance: + await check_conformance(app.user_middleware, str(settings.upstream_url)) + + yield + + return _lifespan + + +__all__ = ["lifespan"] + diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index 5f6ab927..e1625852 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -1,13 +1,16 @@ """Tests for lifespan module.""" from dataclasses import dataclass -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient from starlette.middleware import Middleware from starlette.types import ASGIApp -from stac_auth_proxy import check_conformance, check_server_health +from stac_auth_proxy import Settings, lifespan as lifespan_handler +from stac_auth_proxy.utils.lifespan import check_conformance, check_server_health from stac_auth_proxy.utils.middleware import required_conformance @@ -80,3 +83,25 @@ def __init__(self, app): middleware = [Middleware(NoConformanceMiddleware)] await check_conformance(middleware, source_api_server) + + +def test_lifespan_reusable(): + """Ensure the public lifespan handler runs health and conformance checks.""" + settings = Settings( + upstream_url="https://example.com", + oidc_discovery_url="https://example.com/.well-known/openid-configuration", + ) + with patch( + "stac_auth_proxy.lifespan.check_server_health", + new=AsyncMock(), + ) as mock_health, patch( + "stac_auth_proxy.lifespan.check_conformance", + new=AsyncMock(), + ) as mock_conf: + app = FastAPI(lifespan=lifespan_handler(settings)) + with TestClient(app): + pass + assert mock_health.await_count == 2 + mock_conf.assert_awaited_once_with( + app.user_middleware, str(settings.upstream_url) + ) From 0be89563c522cdb95981b42bad3bb546f9851e97 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 13 Aug 2025 08:02:02 -0700 Subject: [PATCH 05/10] feat: allow lifespan overrides via kwargs --- src/stac_auth_proxy/__init__.py | 4 +++- src/stac_auth_proxy/app.py | 2 +- src/stac_auth_proxy/lifespan.py | 18 ++++++++++++------ tests/test_lifespan.py | 23 +++++++++++++++-------- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/stac_auth_proxy/__init__.py b/src/stac_auth_proxy/__init__.py index 5c4c04d7..cb7e544e 100644 --- a/src/stac_auth_proxy/__init__.py +++ b/src/stac_auth_proxy/__init__.py @@ -8,11 +8,13 @@ from .app import configure_app, create_app from .config import Settings -from .lifespan import lifespan +from .lifespan import check_conformance, check_server_health, lifespan __all__ = [ "create_app", "configure_app", "lifespan", + "check_conformance", + "check_server_health", "Settings", ] diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index 8684483b..959072dd 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -141,7 +141,7 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI: app = FastAPI( openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema - lifespan=lifespan(settings), + lifespan=lifespan(settings=settings), root_path=settings.root_path, ) if app.root_path: diff --git a/src/stac_auth_proxy/lifespan.py b/src/stac_auth_proxy/lifespan.py index e2d86a86..cab315b2 100644 --- a/src/stac_auth_proxy/lifespan.py +++ b/src/stac_auth_proxy/lifespan.py @@ -2,6 +2,8 @@ from contextlib import asynccontextmanager import logging +from typing import Any + from fastapi import FastAPI from .config import Settings @@ -10,14 +12,17 @@ logger = logging.getLogger(__name__) -def lifespan(settings: Settings | None = None): +def lifespan(settings: Settings | None = None, **settings_kwargs: Any): """Create a lifespan handler that runs startup checks. Parameters ---------- - settings : Settings | None - Configuration for health and conformance checks. If omitted, default - settings are loaded. + settings : Settings | None, optional + Pre-built settings instance. If omitted, a new one is constructed from + ``settings_kwargs``. + **settings_kwargs : Any + Keyword arguments used to configure the health and conformance checks if + ``settings`` is not provided. Returns ------- @@ -25,7 +30,8 @@ def lifespan(settings: Settings | None = None): A callable suitable for the ``lifespan`` parameter of ``FastAPI``. """ - settings = settings or Settings() + if settings is None: + settings = Settings(**settings_kwargs) @asynccontextmanager async def _lifespan(app: FastAPI): @@ -54,5 +60,5 @@ async def _lifespan(app: FastAPI): return _lifespan -__all__ = ["lifespan"] +__all__ = ["lifespan", "check_conformance", "check_server_health"] diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index e1625852..865f5c7f 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -9,8 +9,11 @@ from starlette.middleware import Middleware from starlette.types import ASGIApp -from stac_auth_proxy import Settings, lifespan as lifespan_handler -from stac_auth_proxy.utils.lifespan import check_conformance, check_server_health +from stac_auth_proxy import ( + check_conformance, + check_server_health, + lifespan as lifespan_handler, +) from stac_auth_proxy.utils.middleware import required_conformance @@ -87,10 +90,8 @@ def __init__(self, app): def test_lifespan_reusable(): """Ensure the public lifespan handler runs health and conformance checks.""" - settings = Settings( - upstream_url="https://example.com", - oidc_discovery_url="https://example.com/.well-known/openid-configuration", - ) + upstream_url = "https://example.com" + oidc_discovery_url = "https://example.com/.well-known/openid-configuration" with patch( "stac_auth_proxy.lifespan.check_server_health", new=AsyncMock(), @@ -98,10 +99,16 @@ def test_lifespan_reusable(): "stac_auth_proxy.lifespan.check_conformance", new=AsyncMock(), ) as mock_conf: - app = FastAPI(lifespan=lifespan_handler(settings)) + app = FastAPI( + lifespan=lifespan_handler( + upstream_url=upstream_url, + oidc_discovery_url=oidc_discovery_url, + ) + ) with TestClient(app): pass assert mock_health.await_count == 2 + expected_upstream = upstream_url.rstrip("/") + "/" mock_conf.assert_awaited_once_with( - app.user_middleware, str(settings.upstream_url) + app.user_middleware, expected_upstream ) From 909bc351603daccf63d154b97c0cc7c7c6033576 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 14 Aug 2025 10:45:27 -0700 Subject: [PATCH 06/10] fix: ensure non-optional settings in lifespan --- src/stac_auth_proxy/lifespan.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stac_auth_proxy/lifespan.py b/src/stac_auth_proxy/lifespan.py index cab315b2..8ff76b70 100644 --- a/src/stac_auth_proxy/lifespan.py +++ b/src/stac_auth_proxy/lifespan.py @@ -32,6 +32,7 @@ def lifespan(settings: Settings | None = None, **settings_kwargs: Any): if settings is None: settings = Settings(**settings_kwargs) + assert settings is not None @asynccontextmanager async def _lifespan(app: FastAPI): From dd80e749af23fa28a18c4f3f697004990e83d66c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 14 Aug 2025 10:52:11 -0700 Subject: [PATCH 07/10] ci: lint fix --- src/stac_auth_proxy/app.py | 4 +++- src/stac_auth_proxy/lifespan.py | 10 +++++----- tests/test_lifespan.py | 11 +++-------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index 959072dd..1b4ec221 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -13,6 +13,7 @@ from .config import Settings from .handlers import HealthzHandler, ReverseProxyHandler, SwaggerUI +from .lifespan import lifespan from .middleware import ( AddProcessTimeHeaderMiddleware, AuthenticationExtensionMiddleware, @@ -25,7 +26,6 @@ ProcessLinksMiddleware, RemoveRootPathMiddleware, ) -from .lifespan import lifespan logger = logging.getLogger(__name__) @@ -135,6 +135,8 @@ def configure_app(app: FastAPI, settings: Optional[Settings] = None) -> FastAPI: ) return app + + def create_app(settings: Optional[Settings] = None) -> FastAPI: """FastAPI Application Factory.""" settings = settings or Settings() diff --git a/src/stac_auth_proxy/lifespan.py b/src/stac_auth_proxy/lifespan.py index 8ff76b70..8ac8dc7e 100644 --- a/src/stac_auth_proxy/lifespan.py +++ b/src/stac_auth_proxy/lifespan.py @@ -1,7 +1,7 @@ """Reusable lifespan handler for FastAPI applications.""" -from contextlib import asynccontextmanager import logging +from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI @@ -13,7 +13,8 @@ def lifespan(settings: Settings | None = None, **settings_kwargs: Any): - """Create a lifespan handler that runs startup checks. + """ + Create a lifespan handler that runs startup checks. Parameters ---------- @@ -29,13 +30,13 @@ def lifespan(settings: Settings | None = None, **settings_kwargs: Any): Callable[[FastAPI], AsyncContextManager[Any]] A callable suitable for the ``lifespan`` parameter of ``FastAPI``. """ - if settings is None: settings = Settings(**settings_kwargs) - assert settings is not None @asynccontextmanager async def _lifespan(app: FastAPI): + assert settings is not None # Required for type checking + # Wait for upstream servers to become available if settings.wait_for_upstream: logger.info("Running upstream server health checks...") @@ -62,4 +63,3 @@ async def _lifespan(app: FastAPI): __all__ = ["lifespan", "check_conformance", "check_server_health"] - diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index 865f5c7f..7908e1be 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -9,11 +9,8 @@ from starlette.middleware import Middleware from starlette.types import ASGIApp -from stac_auth_proxy import ( - check_conformance, - check_server_health, - lifespan as lifespan_handler, -) +from stac_auth_proxy import check_conformance, check_server_health +from stac_auth_proxy import lifespan as lifespan_handler from stac_auth_proxy.utils.middleware import required_conformance @@ -109,6 +106,4 @@ def test_lifespan_reusable(): pass assert mock_health.await_count == 2 expected_upstream = upstream_url.rstrip("/") + "/" - mock_conf.assert_awaited_once_with( - app.user_middleware, expected_upstream - ) + mock_conf.assert_awaited_once_with(app.user_middleware, expected_upstream) From 9a6680009740fe8ea120abd8f47d56804eb150be Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 14 Aug 2025 13:32:00 -0700 Subject: [PATCH 08/10] Rework lifespan --- src/stac_auth_proxy/__init__.py | 4 - src/stac_auth_proxy/app.py | 8 +- src/stac_auth_proxy/lifespan.py | 118 +++++++++++++++++++++++--- src/stac_auth_proxy/utils/lifespan.py | 93 -------------------- 4 files changed, 108 insertions(+), 115 deletions(-) delete mode 100644 src/stac_auth_proxy/utils/lifespan.py diff --git a/src/stac_auth_proxy/__init__.py b/src/stac_auth_proxy/__init__.py index cb7e544e..2b547a02 100644 --- a/src/stac_auth_proxy/__init__.py +++ b/src/stac_auth_proxy/__init__.py @@ -8,13 +8,9 @@ from .app import configure_app, create_app from .config import Settings -from .lifespan import check_conformance, check_server_health, lifespan __all__ = [ "create_app", "configure_app", - "lifespan", - "check_conformance", - "check_server_health", "Settings", ] diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index 1b4ec221..ecce9498 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -13,7 +13,7 @@ from .config import Settings from .handlers import HealthzHandler, ReverseProxyHandler, SwaggerUI -from .lifespan import lifespan +from .lifespan import build_lifespan from .middleware import ( AddProcessTimeHeaderMiddleware, AuthenticationExtensionMiddleware, @@ -31,11 +31,11 @@ def configure_app(app: FastAPI, settings: Optional[Settings] = None) -> FastAPI: - """Apply routes and middleware to an existing FastAPI app.""" + """Apply routes and middleware to a FastAPI app.""" settings = settings or Settings() # - # Handlers (place catch-all proxy handler last) + # Route Handlers # # If we have customized Swagger UI Init settings (e.g. a provided client_id) @@ -143,7 +143,7 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI: app = FastAPI( openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema - lifespan=lifespan(settings=settings), + lifespan=build_lifespan(settings=settings), root_path=settings.root_path, ) if app.root_path: diff --git a/src/stac_auth_proxy/lifespan.py b/src/stac_auth_proxy/lifespan.py index 8ac8dc7e..04368e01 100644 --- a/src/stac_auth_proxy/lifespan.py +++ b/src/stac_auth_proxy/lifespan.py @@ -1,18 +1,116 @@ """Reusable lifespan handler for FastAPI applications.""" +import asyncio import logging +import re from contextlib import asynccontextmanager from typing import Any +import httpx from fastapi import FastAPI +from pydantic import HttpUrl +from starlette.middleware import Middleware from .config import Settings -from .utils.lifespan import check_conformance, check_server_health logger = logging.getLogger(__name__) +__all__ = ["build_lifespan", "check_conformance", "check_server_health"] + + +async def check_server_healths(*urls: str | HttpUrl) -> None: + """Wait for upstream APIs to become available.""" + logger.info("Running upstream server health checks...") + for url in urls: + await check_server_health(url) + logger.info( + "Upstream servers are healthy:\n%s", + "\n".join([f" - {url}" for url in urls]), + ) + + +async def check_server_health( + url: str | HttpUrl, + max_retries: int = 10, + retry_delay: float = 1.0, + retry_delay_max: float = 5.0, + timeout: float = 5.0, +) -> None: + """Wait for upstream API to become available.""" + # Convert url to string if it's a HttpUrl + if isinstance(url, HttpUrl): + url = str(url) + + async with httpx.AsyncClient( + base_url=url, timeout=timeout, follow_redirects=True + ) as client: + for attempt in range(max_retries): + try: + response = await client.get("/") + response.raise_for_status() + logger.info(f"Upstream API {url!r} is healthy") + return + except httpx.ConnectError as e: + logger.warning(f"Upstream health check for {url!r} failed: {e}") + retry_in = min(retry_delay * (2**attempt), retry_delay_max) + logger.warning( + f"Upstream API {url!r} not healthy, retrying in {retry_in:.1f}s " + f"(attempt {attempt + 1}/{max_retries})" + ) + await asyncio.sleep(retry_in) + + raise RuntimeError( + f"Upstream API {url!r} failed to respond after {max_retries} attempts" + ) + + +async def check_conformance( + middleware_classes: list[Middleware], + api_url: str, + attr_name: str = "__required_conformances__", + endpoint: str = "/conformance", +): + """Check if the upstream API supports a given conformance class.""" + required_conformances: dict[str, list[str]] = {} + for middleware in middleware_classes: + + for conformance in getattr(middleware.cls, attr_name, []): + required_conformances.setdefault(conformance, []).append( + middleware.cls.__name__ + ) + + async with httpx.AsyncClient(base_url=api_url) as client: + response = await client.get(endpoint) + response.raise_for_status() + api_conforms_to = response.json().get("conformsTo", []) + + missing = [ + req_conformance + for req_conformance in required_conformances.keys() + if not any( + re.match(req_conformance, conformance) for conformance in api_conforms_to + ) + ] + + def conformance_str(conformance: str) -> str: + return f" - {conformance} [{','.join(required_conformances[conformance])}]" + + if missing: + missing_str = [conformance_str(c) for c in missing] + raise RuntimeError( + "\n".join( + [ + "Upstream catalog is missing the following conformance classes:", + *missing_str, + ] + ) + ) + logger.info( + "Upstream catalog conforms to the following required conformance classes: \n%s", + "\n".join([conformance_str(c) for c in required_conformances]), + ) -def lifespan(settings: Settings | None = None, **settings_kwargs: Any): +def build_lifespan(settings: Settings | None = None, **settings_kwargs: Any): """ Create a lifespan handler that runs startup checks. @@ -34,18 +132,13 @@ def lifespan(settings: Settings | None = None, **settings_kwargs: Any): settings = Settings(**settings_kwargs) @asynccontextmanager - async def _lifespan(app: FastAPI): + async def lifespan(app: "FastAPI"): assert settings is not None # Required for type checking # Wait for upstream servers to become available if settings.wait_for_upstream: - logger.info("Running upstream server health checks...") - urls = [settings.upstream_url, settings.oidc_discovery_internal_url] - for url in urls: - await check_server_health(url=url) - logger.info( - "Upstream servers are healthy:\n%s", - "\n".join([f" - {url}" for url in urls]), + await check_server_healths( + settings.upstream_url, settings.oidc_discovery_internal_url ) # Log all middleware connected to the app @@ -59,7 +152,4 @@ async def _lifespan(app: FastAPI): yield - return _lifespan - - -__all__ = ["lifespan", "check_conformance", "check_server_health"] + return lifespan diff --git a/src/stac_auth_proxy/utils/lifespan.py b/src/stac_auth_proxy/utils/lifespan.py deleted file mode 100644 index 412fc41c..00000000 --- a/src/stac_auth_proxy/utils/lifespan.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Health check implementations for lifespan events.""" - -import asyncio -import logging -import re - -import httpx -from pydantic import HttpUrl -from starlette.middleware import Middleware - -logger = logging.getLogger(__name__) - - -async def check_server_health( - url: str | HttpUrl, - max_retries: int = 10, - retry_delay: float = 1.0, - retry_delay_max: float = 5.0, - timeout: float = 5.0, -) -> None: - """Wait for upstream API to become available.""" - # Convert url to string if it's a HttpUrl - if isinstance(url, HttpUrl): - url = str(url) - - async with httpx.AsyncClient( - base_url=url, timeout=timeout, follow_redirects=True - ) as client: - for attempt in range(max_retries): - try: - response = await client.get("/") - response.raise_for_status() - logger.info(f"Upstream API {url!r} is healthy") - return - except httpx.ConnectError as e: - logger.warning(f"Upstream health check for {url!r} failed: {e}") - retry_in = min(retry_delay * (2**attempt), retry_delay_max) - logger.warning( - f"Upstream API {url!r} not healthy, retrying in {retry_in:.1f}s " - f"(attempt {attempt + 1}/{max_retries})" - ) - await asyncio.sleep(retry_in) - - raise RuntimeError( - f"Upstream API {url!r} failed to respond after {max_retries} attempts" - ) - - -async def check_conformance( - middleware_classes: list[Middleware], - api_url: str, - attr_name: str = "__required_conformances__", - endpoint: str = "/conformance", -): - """Check if the upstream API supports a given conformance class.""" - required_conformances: dict[str, list[str]] = {} - for middleware in middleware_classes: - - for conformance in getattr(middleware.cls, attr_name, []): - required_conformances.setdefault(conformance, []).append( - middleware.cls.__name__ - ) - - async with httpx.AsyncClient(base_url=api_url) as client: - response = await client.get(endpoint) - response.raise_for_status() - api_conforms_to = response.json().get("conformsTo", []) - - missing = [ - req_conformance - for req_conformance in required_conformances.keys() - if not any( - re.match(req_conformance, conformance) for conformance in api_conforms_to - ) - ] - - def conformance_str(conformance: str) -> str: - return f" - {conformance} [{','.join(required_conformances[conformance])}]" - - if missing: - missing_str = [conformance_str(c) for c in missing] - raise RuntimeError( - "\n".join( - [ - "Upstream catalog is missing the following conformance classes:", - *missing_str, - ] - ) - ) - logger.info( - "Upstream catalog conforms to the following required conformance classes: \n%s", - "\n".join([conformance_str(c) for c in required_conformances]), - ) From d2a45a9c5f4b345c34487d78fbd1364719009f29 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 14 Aug 2025 13:49:14 -0700 Subject: [PATCH 09/10] improve documentation --- docs/user-guide/tips.md | 43 +++++++++++++++++++++++++++------ src/stac_auth_proxy/__init__.py | 2 ++ src/stac_auth_proxy/app.py | 25 ++++++++++++++++--- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/docs/user-guide/tips.md b/docs/user-guide/tips.md index 544e0e0c..8c49d910 100644 --- a/docs/user-guide/tips.md +++ b/docs/user-guide/tips.md @@ -2,12 +2,12 @@ ## CORS -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. +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. Because the STAC Auth Proxy introduces authentication, the upstream API’s CORS settings may need adjustment to support credentials. In most cases, this means: -* [`Access-Control-Allow-Credentials`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) must be `true` -* [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin) must _not_ be `*`[^CORSNotSupportingCredentials] +- [`Access-Control-Allow-Credentials`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) must be `true` +- [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin) must _not_ be `*`[^CORSNotSupportingCredentials] [^CORSNotSupportingCredentials]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors/CORSNotSupportingCredentials @@ -32,13 +32,40 @@ Rather than performing the login flow, the Swagger UI can be configured to accep ```sh OPENAPI_AUTH_SCHEME_NAME=jwtAuth OPENAPI_AUTH_SCHEME_OVERRIDE='{ - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", "description": "Paste your raw JWT here. This API uses Bearer token authorization." }' ``` -## Runtime Customization +## Non-proxy Configuration -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. +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: + +```py +from fastapi import FastAPI +from stac_fastapi.api.app import StacApi +from stac_auth_proxy import build_lifespan, configure_app, Settings as StacAuthSettings + +# Create Auth Settings +auth_settings = StacAuthSettings( + upstream_url='https://stac-server', + oidc_discovery_url='https://auth-server/.well-known/openid-configuration', +) + +# Setup App +app = FastAPI( + ... + lifespan=build_lifespan(auth_settings), +) + +# Apply STAC Auth Proxy middleware +configure_app(app, auth_settings) + +# Setup STAC API +api = StacApi( + app, + ... +) +``` diff --git a/src/stac_auth_proxy/__init__.py b/src/stac_auth_proxy/__init__.py index 2b547a02..4fdde39e 100644 --- a/src/stac_auth_proxy/__init__.py +++ b/src/stac_auth_proxy/__init__.py @@ -8,8 +8,10 @@ from .app import configure_app, create_app from .config import Settings +from .lifespan import build_lifespan __all__ = [ + "build_lifespan", "create_app", "configure_app", "Settings", diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index ecce9498..a5c0aa95 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -6,7 +6,7 @@ """ import logging -from typing import Optional +from typing import Any, Optional from fastapi import FastAPI from starlette_cramjam.middleware import CompressionMiddleware @@ -30,9 +30,26 @@ logger = logging.getLogger(__name__) -def configure_app(app: FastAPI, settings: Optional[Settings] = None) -> FastAPI: - """Apply routes and middleware to a FastAPI app.""" - settings = settings or Settings() +def configure_app( + app: FastAPI, + settings: Optional[Settings] = None, + **settings_kwargs: Any, +) -> FastAPI: + """ + Apply routes and middleware to a FastAPI app. + + Parameters + ---------- + app : FastAPI + The FastAPI app to configure. + settings : Settings | None, optional + Pre-built settings instance. If omitted, a new one is constructed from + ``settings_kwargs``. + **settings_kwargs : Any + Keyword arguments used to configure the health and conformance checks if + ``settings`` is not provided. + """ + settings = settings or Settings(**settings_kwargs) # # Route Handlers From 52766b6cadd35a9ae11f5c66f40789a7fc33f4cf Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 15 Aug 2025 12:34:43 -0700 Subject: [PATCH 10/10] fix: change import --- tests/test_lifespan.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index 7908e1be..654a8009 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -9,8 +9,8 @@ from starlette.middleware import Middleware from starlette.types import ASGIApp -from stac_auth_proxy import check_conformance, check_server_health -from stac_auth_proxy import lifespan as lifespan_handler +from stac_auth_proxy import build_lifespan +from stac_auth_proxy.lifespan import check_conformance, check_server_health from stac_auth_proxy.utils.middleware import required_conformance @@ -97,7 +97,7 @@ def test_lifespan_reusable(): new=AsyncMock(), ) as mock_conf: app = FastAPI( - lifespan=lifespan_handler( + lifespan=build_lifespan( upstream_url=upstream_url, oidc_discovery_url=oidc_discovery_url, )