Skip to content

Commit

Permalink
Document and test FastAPI integration (#32)
Browse files Browse the repository at this point in the history
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.

#28
  • Loading branch information
br3ndonland committed Apr 11, 2024
1 parent 1e8b896 commit 7362541
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 0 deletions.
35 changes: 35 additions & 0 deletions README.md
Expand Up @@ -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).
Expand Down
35 changes: 35 additions & 0 deletions docs/index.md
Expand Up @@ -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)
```
1 change: 1 addition & 0 deletions pyproject.toml
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions 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"

0 comments on commit 7362541

Please sign in to comment.