From 7362541497bb975cb021552ebf2a0fe9a5cd0a99 Mon Sep 17 00:00:00 2001 From: Brendon Smith Date: Thu, 11 Apr 2024 16:18:42 -0400 Subject: [PATCH] Document and test FastAPI integration (#32) One of the goals of this project as shown in the README is to unify settings management for FastAPI. It would be helpful to provide a simple example of how to integrate fastenv with a FastAPI app. The most common use case for fastenv would be to load environment variables and settings when the FastAPI app starts up. The recommended way to customize app startup and shutdown is with lifespan events. This PR will add an example to the quickstart in the README that uses [lifespan events](https://fastapi.tiangolo.com/advanced/events/) with [lifespan state](https://www.starlette.io/lifespan/#lifespan-state). Lifespan state is the recommended way to share objects between the lifespan function and API endpoints. Currently, the lifespan function can only have one required argument for the FastAPI or Starlette app instance. This is because of the way Starlette runs the lifespan function, as seen in the source code [here](https://github.com/encode/starlette/blob/4e453ce91940cc7c995e6c728e3fdf341c039056/starlette/routing.py#L732). This is shown, but not explained, in the [FastAPI docs on lifespan events](https://fastapi.tiangolo.com/advanced/events/) - the code examples use objects from outside the lifespan function by instantiating them at the top-level of the module. Unfortunately this limits lifespan event customization. For example, an application might want a way to customize the dotenv file path or the object storage bucket from which the dotenv file needs to be downloaded. One way to customize the dotenv file path is to set an environment variable with the dotenv file path, then pass the environment variable value into `fastenv.load_dotenv()`. This is demonstrated in the new tests. The new tests will build on the example in the README by loading a dotenv file into a FastAPI app instance with `fastenv.load_dotenv()`. The resultant `DotEnv` instance will then be accessed within an API endpoint by reading the lifespan state on `request.state`. As explained in the [Starlette lifespan docs](https://www.starlette.io/lifespan/), the `TestClient` must be used as a context manager to trigger lifespan. https://github.com/br3ndonland/fastenv/discussions/28 --- README.md | 35 +++++++++++++++++++++++++ docs/index.md | 35 +++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_fastapi.py | 60 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 tests/test_fastapi.py diff --git a/README.md b/README.md index d7a5cb6..5797b03 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,41 @@ anyio.run(fastenv.dump_dotenv, dotenv) # Path('/path/to/this/dir/.env') ``` +Use fastenv in your FastAPI app: + +```py +from contextlib import asynccontextmanager +from typing import AsyncIterator, TypedDict + +import fastenv +from fastapi import FastAPI, Request + + +class LifespanState(TypedDict): + settings: fastenv.DotEnv + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[LifespanState]: + """Configure app lifespan. + + https://fastapi.tiangolo.com/advanced/events/ + https://www.starlette.io/lifespan/ + """ + settings = await fastenv.load_dotenv(".env") + lifespan_state: LifespanState = {"settings": settings} + yield lifespan_state + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/settings") +async def get_settings(request: Request) -> dict[str, str]: + settings = request.state.settings + return dict(settings) +``` + ## Documentation Documentation is built with [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), deployed on [Vercel](https://vercel.com/), and available at [fastenv.bws.bio](https://fastenv.bws.bio) and [fastenv.vercel.app](https://fastenv.vercel.app). diff --git a/docs/index.md b/docs/index.md index 9d5116d..5c29fc1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,3 +56,38 @@ import anyio anyio.run(fastenv.dump_dotenv, dotenv) # Path('/path/to/this/dir/.env') ``` + +Use fastenv in your FastAPI app: + +```py +from contextlib import asynccontextmanager +from typing import AsyncIterator, TypedDict + +import fastenv +from fastapi import FastAPI, Request + + +class LifespanState(TypedDict): + settings: fastenv.DotEnv + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[LifespanState]: + """Configure app lifespan. + + https://fastapi.tiangolo.com/advanced/events/ + https://www.starlette.io/lifespan/ + """ + settings = await fastenv.load_dotenv(".env") + lifespan_state: LifespanState = {"settings": settings} + yield lifespan_state + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/settings") +async def get_settings(request: Request) -> dict[str, str]: + settings = request.state.settings + return dict(settings) +``` diff --git a/pyproject.toml b/pyproject.toml index ce8cf4b..d224946 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ httpx = [ ] tests = [ "coverage[toml]>=7,<8", + "fastapi>=0.110.1,<0.111", "freezegun>=1,<2", "httpx>=0.23,<1", "pytest>=8.1.1,<9", diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py new file mode 100644 index 0000000..0c07731 --- /dev/null +++ b/tests/test_fastapi.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import AsyncGenerator, AsyncIterator, Dict, TypedDict + +import pytest +from anyio import Path +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +import fastenv + + +class LifespanState(TypedDict): + settings: fastenv.DotEnv + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[LifespanState]: + """Configure app lifespan. + + https://fastapi.tiangolo.com/advanced/events/ + https://www.starlette.io/lifespan/ + """ + env_file = os.environ["ENV_FILE"] + settings = await fastenv.load_dotenv(env_file) + lifespan_state: LifespanState = {"settings": settings} + yield lifespan_state + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/settings") +async def get_settings(request: Request) -> Dict[str, str]: + settings = request.state.settings + return dict(settings) + + +@pytest.fixture +async def test_client( + env_file: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncGenerator[TestClient, None]: + """Instantiate a FastAPI test client. + + https://fastapi.tiangolo.com/tutorial/testing/ + https://www.starlette.io/testclient/ + """ + monkeypatch.setenv("ENV_FILE", str(env_file)) + with TestClient(app) as test_client: + yield test_client + + +@pytest.mark.anyio +async def test_fastapi_with_fastenv(test_client: TestClient) -> None: + """Test loading a dotenv file into a FastAPI app with fastenv.""" + response = test_client.get("/settings") + response_json = response.json() + assert response_json["AWS_ACCESS_KEY_ID_EXAMPLE"] == "AKIAIOSFODNN7EXAMPLE"