Skip to content

Commit

Permalink
Add FastAPI integration (#30)
Browse files Browse the repository at this point in the history
Co-authored-by: Sebastián Ramírez <[email protected]>
  • Loading branch information
hynek and tiangolo authored Aug 15, 2023
1 parent 141b0f6 commit 8421547
Show file tree
Hide file tree
Showing 17 changed files with 535 additions and 6 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/

## [Unreleased](https://github.com/hynek/svcs/compare/23.16.0...HEAD)

### Added

- FastAPI integration.
[#20](https://github.com/hynek/svcs/pull/30)


## [23.16.0](https://github.com/hynek/svcs/compare/23.15.0...23.16.0) - 2023-08-14

Expand All @@ -38,7 +43,7 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/

- Cleanups for services are internally context managers now.
For your convenience, if you pass an (async) generator function for a factory, the registry will automatically wrap it for you into an (async) context manager.
[#92](https://github.com/hynek/svcs/pull/29)
[#29](https://github.com/hynek/svcs/pull/29)

- Pyramid: `svcs.pyramid.get()` now takes a Pyramid request as the first argument.
`svcs.pyramid.get_pings()` also doesn't look at thread locals anymore.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def view(request):
To a type checker like [Mypy](https://mypy-lang.org), `db` has the type `Database`, `api` has the type `WebAPIClient`, and `cache` has the type `Cache`.
<!-- end addendum -->

*svcs* comes with seamless integration for **AIOHTTP**, **Flask**, **Pyramid**, and first-class **async** support.
*svcs* comes with seamless integration for **AIOHTTP**, **FastAPI**, **Flask**, **Pyramid**, and first-class **async** support.

<!-- begin typing -->
While *svcs* also has first-class support for static typing, it is **strictly optional** and will always remain so.
Expand Down
3 changes: 2 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@

from sybil import Sybil
from sybil.parsers import myst, rest
from tests.ifaces import Service

import svcs

from tests.ifaces import Service


markdown_examples = Sybil(
parsers=[
Expand Down
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@
exclude_patterns = ["_build"]

nitpick_ignore = [
("py:class", "AbstractAsyncContextManager"),
("py:class", "aiohttp.web_request.Request"),
("py:class", "FastAPI"),
]

# If true, '()' will be appended to :func: etc. cross-reference text.
Expand Down
44 changes: 44 additions & 0 deletions docs/examples/fastapi/health_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from typing import AsyncGenerator

from fastapi import FastAPI
from fastapi.responses import JSONResponse

import svcs


@svcs.fastapi.lifespan
async def lifespan(
app: FastAPI, registry: svcs.Registry
) -> AsyncGenerator[dict[str, object], None]:
# Register your services here using the *registry* argument.

yield {"your": "other state"}


app = FastAPI(lifespan=lifespan)

##############################################################################


@app.get("/healthy")
async def healthy(services: svcs.fastapi.DepContainer) -> JSONResponse:
"""
Ping all external services.
"""
ok: list[str] = []
failing: list[dict[str, str]] = []
code = 200

for svc in services.get_pings():
try:
await svc.aping()
ok.append(svc.name)
except Exception as e:
failing.append({svc.name: repr(e)})
code = 500

return JSONResponse(
content={"ok": ok, "failing": failing}, status_code=code
)
47 changes: 47 additions & 0 deletions docs/examples/fastapi/simple_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

import os

from typing import AsyncGenerator

from fastapi import FastAPI

import svcs


config = {"db_url": os.environ.get("DB_URL", "sqlite:///:memory:")}


class Database:
@classmethod
async def connect(cls, db_url: str) -> Database:
...
return Database()

async def get_user(self, user_id: int) -> dict[str, str]:
return {} # not interesting here


@svcs.fastapi.lifespan
async def lifespan(
app: FastAPI, registry: svcs.Registry
) -> AsyncGenerator[dict[str, object], None]:
async def connect_to_db() -> Database:
return await Database.connect(config["db_url"])

registry.register_factory(Database, connect_to_db)

yield {"your": "other stuff"}


app = FastAPI(lifespan=lifespan)


@app.get("/users/{user_id}")
async def get_user(user_id: int, services: svcs.fastapi.DepContainer) -> dict:
db = await services.aget(Database)

try:
return {"data": await db.get_user(user_id)}
except Exception as e:
return {"oh no": e.args[0]}
28 changes: 28 additions & 0 deletions docs/examples/fastapi/test_simple_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from unittest.mock import Mock

import pytest

from fastapi.testclient import TestClient

from simple_app import Database, app, lifespan


@pytest.fixture(name="client")
def _client():
with TestClient(app) as client:
yield client


def test_db_goes_boom(client):
"""
Database errors are handled gracefully.
"""

# IMPORTANT: Overwriting must happen AFTER the app is ready!
db = Mock(spec_set=Database)
db.get_user.side_effect = Exception("boom")
lifespan.registry.register_value(Database, db)

resp = client.get("/users/42")

assert {"oh no": "boom"} == resp.json()
11 changes: 11 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ async def view(request):
...
```
:::
::: {tab} FastAPI
```python
import svcs

@app.get("/")
async def view(services: svcs.fastapi.DepContainer):
db, api, cache = await services.aget(Database, WebAPI, Cache)

...
```
:::
::: {tab} Flask
```python
import svcs
Expand Down
121 changes: 121 additions & 0 deletions docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# FastAPI

*svcs*'s *centralization* and *on-demand* capabilities are a great complement to FastAPI's dependency injection system – especially when the annotated decorator approach becomes unwieldy because of too many dependencies.

*svcs*'s [FastAPI](https://fastapi.tiangolo.com) integration stores the {class}`svcs.Registry` on the *lifespan state* and the {class}`svcs.Container` is a [dependency](https://fastapi.tiangolo.com/tutorial/dependencies/) that can be injected into your views.
That makes very little API necessary from *svcs* itself.

(fastapi-init)=

## Initialization

FastAPI inherited the [`request.state`](https://www.starlette.io/requests/#other-state) attribute from [*starlette*](https://www.starlette.io/) and *svcs* uses it to store the {class}`svcs.Registry` on it.

To get it there you have to instantiate your FastAPI application with a *lifespan*.
Whatever this lifespan yields, becomes the initial request state via shallow copy.

To keep track of its registry for later overwriting, *svcs* comes with the {class}`svcs.fastapi.lifespan` decorator that remembers the registry on the lifespan object (see below in testing to see it in action):

```python
from fastapi import FastAPI

import svcs


@svcs.fastapi.lifespan
async def lifespan(app: FastAPI, registry: svcs.Registry):
registry.register_factory(Database, Database.connect)

yield {"your": "other", "initial": "state"}

# Registry is closed automatically when the app is done.


app = FastAPI(lifespan=lifespan)
```

::: {seealso}
- [Lifespan state](https://www.starlette.io/lifespan/) in *starlette* documentation.
- [Lifespan](https://fastapi.tiangolo.com/advanced/events/) in FastAPI documentation (more verbose, but doesn't mention lifespan state).
:::

(fastapi-get)=

## Service Acquisition

*svcs* comes with the {func}`svcs.fastapi.container` dependency that will inject a request-scoped {class}`svcs.Container` into your views if the application is correctly initialized:

```python
from typing import Annotated

from fastapi import Depends


@app.get("/")
async def index(services: Annotated[svcs.Container, Depends(svcs.fastapi.container)]):
db = services.get(Database)
```

For your convenience, *svcs* comes with the alias {class}`svcs.fastapi.DepContainer` that allows you to use the shorter and even nicer:

```python
@app.get("/")
async def index(services: svcs.fastapi.DepContainer):
db = services.get(Database)
```

(fastapi-health)=

## Health Checks

With the help of the {func}`svcs.fastapi.container` dependency you can easily add a health check endpoint to your application without any special API:

```{literalinclude} ../examples/fastapi/health_check.py
```


## Testing

The centralized service registry makes it straight-forward to selectively replace dependencies within your application in tests even if you have many dependencies to handle.

Let's take this simple FastAPI application as an example:

```{literalinclude} ../examples/fastapi/simple_app.py
```

Now if you want to make a request against the `get_user` view, but want the database to raise an error to see if it's properly handled, you can do this:

```{literalinclude} ../examples/fastapi/test_simple_app.py
```

As you can see, we can inspect the decorated lifespan function to get the registry that got injected and you can overwrite it later.

::: {important}
You must overwrite *after* the application has been initialized.
Otherwise the lifespan function overwrites your settings.
:::


## Cleanup

If you initialize the application with a lifespan as shown above, and use the {func}`svcs.fastapi.container` dependency to get your services, everything is cleaned up behind you automatically.


## API Reference

### Application Life Cycle

```{eval-rst}
.. autoclass:: svcs.fastapi.lifespan(lifespan)
.. seealso:: :ref:`fastapi-init`
```


### Service Acquisition

```{eval-rst}
.. autofunction:: svcs.fastapi.container
.. autoclass:: svcs.fastapi.DepContainer
```
1 change: 1 addition & 0 deletions docs/integrations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Here's documentation on those we ship and how you can write your own.
:maxdepth: 1
aiohttp
fastapi
flask
pyramid
custom
Expand Down
4 changes: 4 additions & 0 deletions docs/why.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ This is how it could look with the shipped integrations:
```{literalinclude} examples/aiohttp/health_check.py
```
:::
::: {tab} FastAPI
```{literalinclude} examples/fastapi/health_check.py
```
:::
::: {tab} Flask
```{literalinclude} examples/flask/health_check.py
```
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies = [
tests = ["pytest", "pytest-asyncio", "sybil"]
typing = [
"mypy>=1.4",
"fastapi",
"flask",
"aiohttp; python_version<'3.12'",
"pyramid; python_version<'3.12'",
Expand All @@ -42,7 +43,9 @@ docs = [
"furo",
"sybil",
"pytest",
"httpx",
"aiohttp",
"fastapi",
"flask",
"pyramid",
]
Expand Down Expand Up @@ -162,6 +165,10 @@ allow_any_generics = true
module = "pyramid.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "pytest.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true
Expand Down Expand Up @@ -236,3 +243,4 @@ ignore = [
[tool.ruff.isort]
lines-between-types = 1
lines-after-imports = 2
known-first-party = ["svcs", "tests", "simple_app"]
5 changes: 5 additions & 0 deletions src/svcs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
except ImportError:
__all__ += ["aiohttp"]

try:
from . import fastapi
except ImportError:
__all__ += ["fastapi"]

try:
from . import flask
except ImportError:
Expand Down
Loading

0 comments on commit 8421547

Please sign in to comment.