From a45604ff2f5a6506f1dc26d50320ba3b532b736e Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Fri, 13 Feb 2026 13:59:59 +0100 Subject: [PATCH] feat: add lifespan integration and dependency injection --- src/fastapi_oidc_op/app.py | 36 ++++++++++++++++++++++++++++- src/fastapi_oidc_op/dependencies.py | 19 +++++++++++++++ tests/conftest.py | 4 ++-- tests/test_app.py | 32 +++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 src/fastapi_oidc_op/dependencies.py diff --git a/src/fastapi_oidc_op/app.py b/src/fastapi_oidc_op/app.py index 908dede..11f81ee 100644 --- a/src/fastapi_oidc_op/app.py +++ b/src/fastapi_oidc_op/app.py @@ -1,6 +1,39 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import aiosqlite from fastapi import FastAPI -from fastapi_oidc_op.config import Settings +from fastapi_oidc_op.config import Settings, StorageBackend +from fastapi_oidc_op.store.sqlite.migrations import run_migrations +from fastapi_oidc_op.store.sqlite.repositories import ( + SQLiteCredentialRepository, + SQLiteMagicLinkRepository, + SQLiteUserRepository, +) + +MIGRATIONS_DIR = Path(__file__).parent / "store" / "sqlite" / "migrations" + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings: Settings = app.state.settings + if settings.storage_backend == StorageBackend.SQLITE: + if settings.sqlite_path != ":memory:": + Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True) + db = await aiosqlite.connect(settings.sqlite_path) + db.row_factory = aiosqlite.Row + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA foreign_keys=ON") + await run_migrations(db, MIGRATIONS_DIR) + app.state.user_repo = SQLiteUserRepository(db) + app.state.credential_repo = SQLiteCredentialRepository(db) + app.state.magic_link_repo = SQLiteMagicLinkRepository(db) + yield + await db.close() + else: + raise NotImplementedError("MongoDB backend not yet implemented") def create_app(settings: Settings | None = None) -> FastAPI: @@ -12,6 +45,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: version="0.1.0", docs_url="/docs" if settings.debug else None, redoc_url=None, + lifespan=lifespan, ) app.state.settings = settings diff --git a/src/fastapi_oidc_op/dependencies.py b/src/fastapi_oidc_op/dependencies.py new file mode 100644 index 0000000..29675d0 --- /dev/null +++ b/src/fastapi_oidc_op/dependencies.py @@ -0,0 +1,19 @@ +from fastapi import Request + +from fastapi_oidc_op.store.protocols import ( + CredentialRepository, + MagicLinkRepository, + UserRepository, +) + + +def get_user_repo(request: Request) -> UserRepository: + return request.app.state.user_repo + + +def get_credential_repo(request: Request) -> CredentialRepository: + return request.app.state.credential_repo + + +def get_magic_link_repo(request: Request) -> MagicLinkRepository: + return request.app.state.magic_link_repo diff --git a/tests/conftest.py b/tests/conftest.py index 9649412..239ea06 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,12 +9,12 @@ from fastapi_oidc_op.config import Settings @pytest.fixture def settings() -> Settings: - return Settings(issuer="http://localhost:8000") + return Settings(issuer="http://localhost:8000", sqlite_path=":memory:") @pytest.fixture async def client(settings: Settings) -> AsyncIterator[AsyncClient]: app = create_app(settings) transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url=settings.issuer) as ac: + async with app.router.lifespan_context(app), AsyncClient(transport=transport, base_url=settings.issuer) as ac: yield ac diff --git a/tests/test_app.py b/tests/test_app.py index 232969d..796a3e9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -13,3 +13,35 @@ async def test_app_has_title(client: AsyncClient) -> None: assert response.status_code == 200 data = response.json() assert data["info"]["title"] == "FastAPI OIDC OP" + + +async def test_app_has_repos_on_state(client: AsyncClient) -> None: + from fastapi_oidc_op.store.protocols import ( + CredentialRepository, + MagicLinkRepository, + UserRepository, + ) + + app = client._transport.app # type: ignore[union-attr] + assert isinstance(app.state.user_repo, UserRepository) + assert isinstance(app.state.credential_repo, CredentialRepository) + assert isinstance(app.state.magic_link_repo, MagicLinkRepository) + + +async def test_dependency_functions() -> None: + from unittest.mock import MagicMock + + from fastapi_oidc_op.dependencies import ( + get_credential_repo, + get_magic_link_repo, + get_user_repo, + ) + + request = MagicMock() + request.app.state.user_repo = "user_repo_sentinel" + request.app.state.credential_repo = "credential_repo_sentinel" + request.app.state.magic_link_repo = "magic_link_repo_sentinel" + + assert get_user_repo(request) == "user_repo_sentinel" + assert get_credential_repo(request) == "credential_repo_sentinel" + assert get_magic_link_repo(request) == "magic_link_repo_sentinel"