Skip to content

Commit 4b83ceb

Browse files
authored
Initial work on adding docs for FastAPISessionMaker (#6)
* Add docs for FastAPISessionMaker
1 parent 854a520 commit 4b83ceb

File tree

9 files changed

+222
-20
lines changed

9 files changed

+222
-20
lines changed

.github/workflows/build.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ jobs:
4040
with:
4141
path: .pytest_cache
4242
key: pytest-${{ matrix.python-version }}
43+
- name: Check docs build
44+
# only run this for the python version used by netlify:
45+
if: matrix.python-version == 3.7
46+
run: |
47+
make docs-build-ci
4348
- name: Check that formatting, linting, and tests pass
4449
run: |
4550
make ci
4651
- name: Submit coverage report
4752
run: |
4853
codecov --token=${{ secrets.CODECOV_TOKEN }}
49-
- name: Check docs build
50-
# only run this for the python version used by netlify:
51-
if: matrix.python-version == 3.7
52-
run: |
53-
make docs-build-ci

.github/workflows/pull-request.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ jobs:
3838
with:
3939
path: .pytest_cache
4040
key: pytest-${{ matrix.python-version }}
41+
- name: Check docs build
42+
# only run this for the python version used by netlify:
43+
if: matrix.python-version == 3.7
44+
run: |
45+
make docs-build-ci
4146
- name: Check that formatting, linting, and tests pass
4247
run: |
4348
make ci
4449
- name: Submit coverage report
4550
run: |
4651
codecov --token=${{ secrets.CODECOV_TOKEN }}
47-
- name: Check docs build
48-
# only run this for the python version used by netlify:
49-
if: matrix.python-version == 3.7
50-
run: |
51-
make docs-build-ci

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
</p>
44
<p align="center">
55
<img src="https://img.shields.io/github/last-commit/dmontagu/fastapi-utils.svg">
6-
<a href="https://github.com/dmontagu/" target="_blank">
6+
<a href="https://github.com/dmontagu/fastapi-utils" target="_blank">
77
<img src="https://github.com/dmontagu/fastapi-utils/workflows/build/badge.svg" alt="Build">
88
</a>
99
<a href="https://codecov.io/gh/dmontagu/fastapi-utils" target="_blank">

docs/src/session1.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from functools import lru_cache
2+
from typing import Iterator
3+
from uuid import UUID
4+
5+
import sqlalchemy as sa
6+
from fastapi import Depends, FastAPI
7+
from pydantic import BaseSettings
8+
from sqlalchemy.ext.declarative import declarative_base
9+
from sqlalchemy.orm import Session
10+
11+
from fastapi_utils.guid_type import GUID, GUID_DEFAULT_SQLITE
12+
from fastapi_utils.session import FastAPISessionMaker
13+
14+
Base = declarative_base()
15+
16+
17+
class User(Base):
18+
__tablename__ = "user"
19+
id = sa.Column(GUID, primary_key=True, default=GUID_DEFAULT_SQLITE)
20+
name = sa.Column(sa.String, nullable=False)
21+
22+
23+
class DBSettings(BaseSettings):
24+
""" Parses variables from environment on instantiation """
25+
26+
database_uri: str # could break up into scheme, username, password, host, db
27+
28+
29+
def get_db() -> Iterator[Session]:
30+
""" FastAPI dependency that provides a sqlalchemy session """
31+
yield from _get_fastapi_sessionmaker().get_db()
32+
33+
34+
@lru_cache()
35+
def _get_fastapi_sessionmaker() -> FastAPISessionMaker:
36+
""" This function could be replaced with a global variable if preferred """
37+
database_uri = DBSettings().database_uri
38+
return FastAPISessionMaker(database_uri)
39+
40+
41+
app = FastAPI()
42+
43+
44+
@app.get("/{user_id}")
45+
def get_user_name(db: Session = Depends(get_db), *, user_id: UUID) -> str:
46+
user = db.query(User).get(user_id)
47+
username = user.name
48+
return username

docs/user-guide/repeated-tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Here's a hypothetical example that could be used to periodically clean up expire
3131
{!./src/repeated_tasks1.py!}
3232
```
3333

34-
(You may want to reference the [sessions docs](sessions.md){.internal-link target=_blank} for more
34+
(You may want to reference the [sessions docs](session.md){.internal-link target=_blank} for more
3535
information about `FastAPISessionMaker`.)
3636

3737
By passing `seconds=60 * 60`, we ensure that the decorated function is called once every hour.

docs/user-guide/session.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#### Source module: [`fastapi_utils.sessions`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/session.py){.internal-link target=_blank}
2+
3+
---
4+
5+
One of the most commonly used ways to power database functionality with FastAPI is SQLAlchemy's ORM.
6+
7+
FastAPI has [great documentation](https://fastapi.tiangolo.com/tutorial/sql-databases/) about how to integrate
8+
ORM into your application.
9+
10+
However, the recommended approach for using SQLAlchemy's ORM with FastAPI has evolved over time to reflect both insights
11+
from the community and the addition of new features to FastAPI.
12+
13+
The `fastapi_utils.session` module contains an implementation making use of the most up-to-date best practices for
14+
managing SQLAlchemy sessions with FastAPI.
15+
16+
---
17+
18+
## `FastAPISessionMaker`
19+
The `fastapi_utils.session.FastAPISessionMaker` class conveniently wraps session-making functionality for use with
20+
FastAPI. This section contains an example showing how to use this class.
21+
22+
Let's begin with some infrastructure. The first thing we'll do is make sure we have an ORM
23+
table to query:
24+
25+
```python hl_lines="8 9 11 14 17 18 19 20"
26+
{!./src/session1.py!}
27+
```
28+
29+
Next, we set up infrastructure for loading the database uri from the environment:
30+
31+
```python hl_lines="23 24 25 26"
32+
{!./src/session1.py!}
33+
```
34+
35+
We use the `pydantic.BaseSettings` to load variables from the environment. There is documentation for this class in the
36+
<a href="https://pydantic-docs.helpmanual.io/usage/settings/" class="external-link" target="_blank">pydantic docs</a>,
37+
but the basic idea is that if a model inherits from this class, any fields not specified during initialization
38+
are read from the environment if possible.
39+
40+
!!! info
41+
Since `database_uri` is not an optional field, a `ValidationError` will be raised if the `DATABASE_URI` environment
42+
variable is not set.
43+
44+
!!! info
45+
For finer grained control, you could remove the `database_uri` field, and replace it with
46+
separate fields for `scheme`, `username`, `password`, `host`, and `db`. You could then give the model a `@property`
47+
called `database_uri` that builds the uri from these components.
48+
49+
Now that we have a way to load the database uri, we can create the FastAPI dependency we'll use
50+
to obtain the sqlalchemy session:
51+
52+
```python hl_lines="29 30 31 34 35 36 37 38"
53+
{!./src/session1.py!}
54+
```
55+
56+
!!! info
57+
The `get_db` dependency makes use of a context-manager dependency, rather than a middleware-based approach.
58+
This means that any endpoints that don't make use of a sqlalchemy session will not be exposed to any
59+
session-related overhead.
60+
61+
This is in contrast with middleware-based approaches, where the handling of every request would result in
62+
a session being created and closed, even if the endpoint would not make use of it.
63+
64+
!!! warning
65+
The `get_db` dependency **will not finalize your ORM session until *after* a response is returned to the user**.
66+
67+
This has minor response-latency benefits, but also means that if you have any uncommitted
68+
database writes that will raise errors, you may return a success response to the user (status code 200),
69+
but still raise an error afterward during request clean-up.
70+
71+
To deal with this, for any request where you expect a database write to potentially fail, you should **manually
72+
perform a commit inside your endpoint logic and appropriately handle any resulting errors.**
73+
74+
-----
75+
76+
Note that while middleware-based approaches can automatically ensure database errors are visible to users, the
77+
result would be a generic 500 internal server error, which you should strive to avoid sending to clients under
78+
normal circumstances.
79+
80+
You can still log any database errors raised during cleanup by appropriately modifying the `get_db` function
81+
with a `try: except:` block.
82+
83+
The `get_db` function can be used as a FastAPI dependency that will inject a sqlalchemy ORM session where used:
84+
85+
```python hl_lines="45 46"
86+
{!./src/session1.py!}
87+
```
88+
89+
!!! info
90+
We make use of `@lru_cache` on `_get_fastapi_sessionmaker` to ensure the same `FastAPISessionMaker` instance is
91+
reused across requests. This reduces the per-request overhead while still ensuring the instance is created
92+
lazily, making it possible to have the `database_uri` reflect modifications to the environment performed *after*
93+
importing the relevant source file.
94+
95+
This can be especially useful during testing if you want to override environment variables programmatically using
96+
your testing framework.

docs/user-guide/sessions.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

fastapi_utils/session.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66

77

88
class FastAPISessionMaker:
9+
"""
10+
This class provides a convenient cached interface for accessing sqlalchemy ORM sessions using just a database URI.
11+
12+
The expected format of database_uri is `"<scheme>://<user>:<password>@<host>:<port>/<database>`, exactly as you'd
13+
use with `sqlalchemy.create_engine`.
14+
15+
For example, if a postgres database named `app` is accessible on a container named `db`,
16+
the `database_uri` might look like: "postgresql://db_user:password@db:5432/app"
17+
"""
18+
919
def __init__(self, database_uri: str):
1020
self.database_uri = database_uri
1121

@@ -14,6 +24,9 @@ def __init__(self, database_uri: str):
1424

1525
@property
1626
def cached_engine(self) -> sa.engine.Engine:
27+
"""
28+
Returns the cached engine if present, or a new (cached) engine using the database_uri if not
29+
"""
1730
engine = self._cached_engine
1831
if engine is None:
1932
engine = self.get_new_engine()
@@ -22,49 +35,99 @@ def cached_engine(self) -> sa.engine.Engine:
2235

2336
@property
2437
def cached_sessionmaker(self) -> sa.orm.sessionmaker:
38+
"""
39+
Returns the cached sessionmaker if present, or a new (cached) sessionmaker using the cached_engine if not
40+
"""
2541
sessionmaker = self._cached_sessionmaker
2642
if sessionmaker is None:
2743
sessionmaker = self.get_new_sessionmaker(self.cached_engine)
2844
self._cached_sessionmaker = sessionmaker
2945
return sessionmaker
3046

31-
def get_new_engine(self,) -> sa.engine.Engine:
47+
def get_new_engine(self) -> sa.engine.Engine:
48+
"""
49+
Returns a new sqlalchemy engine for the database_uri.
50+
"""
3251
return get_engine(self.database_uri)
3352

3453
def get_new_sessionmaker(self, engine: Optional[sa.engine.Engine]) -> sa.orm.sessionmaker:
54+
"""
55+
Returns a new sessionmaker for the (optional) provided engine.
56+
57+
If `None` is provided, the cached engine is used. (A new engine is created and cached if necessary.)
58+
"""
3559
engine = engine or self.cached_engine
3660
return get_sessionmaker_for_engine(engine)
3761

3862
def get_db(self) -> Iterator[Session]:
3963
"""
40-
Intended for use as a FastAPI dependency
64+
A FastAPI dependency that yields a sqlalchemy session.
65+
66+
The session is created by the cached sessionmaker, and closed via contextmanager after the response is returned.
67+
68+
Note that if you perform any database writes and want to handle errors *prior* to returning a response (and you
69+
should!), you'll need to put `session.commit()` or `session.rollback()` as appropriate in your endpoint code.
70+
This is generally a best practice for expected errors anyway since otherwise you would generate a 500 response.
4171
"""
4272
yield from _get_db(self.cached_sessionmaker)
4373

4474
@contextmanager
4575
def context_session(self) -> Iterator[Session]:
76+
"""
77+
This method directly produces a context-managed session without relying on FastAPI's dependency injection.
78+
79+
Usage would look like:
80+
```python
81+
session_maker = FastAPISessionMaker(db_uri)
82+
with session_maker.context_session() as session:
83+
instance = session.query(OrmModel).get(instance_id)
84+
```
85+
"""
4686
yield from self.get_db()
4787

4888
def reset_cache(self) -> None:
89+
"""
90+
Resets the engine and sessionmaker caches.
91+
92+
After calling this method, the next time you try to use the cached engine or sessionmaker,
93+
new ones will be created.
94+
"""
4995
self._cached_engine = None
5096
self._cached_sessionmaker = None
5197

5298

5399
def get_engine(uri: str) -> sa.engine.Engine:
100+
"""
101+
Returns a new sqlalchemy engine that "tests connections for liveness upon each checkout".
102+
"""
54103
return sa.create_engine(uri, pool_pre_ping=True)
55104

56105

57106
def get_sessionmaker_for_engine(engine: sa.engine.Engine) -> sa.orm.sessionmaker:
107+
"""
108+
Returns a sqlalchemy sessionmaker for the provided engine, using recommended settings for use with FastAPI.
109+
"""
58110
return sa.orm.sessionmaker(autocommit=False, autoflush=False, bind=engine)
59111

60112

61113
@contextmanager
62114
def context_session(engine: sa.engine.Engine) -> Iterator[Session]:
115+
"""
116+
This method produces a context-managed session for use with a specified engine.
117+
118+
Behaves similarly to FastAPISessionMaker.context_session.
119+
"""
63120
sessionmaker = get_sessionmaker_for_engine(engine)
64121
yield from _get_db(sessionmaker)
65122

66123

67124
def _get_db(sessionmaker: sa.orm.sessionmaker) -> Iterator[Session]:
125+
"""
126+
The underlying generator function used to create context-managed sqlalchemy sessions for:
127+
* context_session
128+
* FastAPISessionMaker.context_session
129+
* FastAPISessionMaker.get_db
130+
"""
68131
session = sessionmaker()
69132
try:
70133
yield session

mkdocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ nav:
2424
- Inferring Router: 'user-guide/inferring-router.md'
2525
- Repeated Tasks: 'user-guide/repeated-tasks.md'
2626
- Timing Middleware: 'user-guide/timing-middleware.md'
27-
- SQLAlchemy Sessions: 'user-guide/sessions.md'
27+
- SQLAlchemy Sessions: 'user-guide/session.md'
2828
- OpenAPI Spec Simplification: 'user-guide/openapi.md'
2929
- Other Utilities:
3030
- APIModel: 'user-guide/basics/api-model.md'

0 commit comments

Comments
 (0)