Fastapi sqlalchemy example (#389)

* Add application

* Dockerize the app

* Fix 204 content-leength error

* Rename database file

* Add tests

* Add README

* Fix a typo in FastAPI example

* Add docs on FastAPI + SQLAlchemy example

* Update changelog

* Add link to the example to README and other docs pages

* Add EOF to the config.yml
This commit is contained in:
Roman Mogylatov 2021-02-04 18:18:25 -05:00 committed by GitHub
parent a1f779a9f3
commit d45d98e300
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 614 additions and 1 deletions

View File

@ -161,6 +161,7 @@ Choose one of the following:
- `Sanic example <https://python-dependency-injector.ets-labs.org/examples/sanic.html>`_
- `FastAPI example <https://python-dependency-injector.ets-labs.org/examples/fastapi.html>`_
- `FastAPI + Redis example <https://python-dependency-injector.ets-labs.org/examples/fastapi-redis.html>`_
- `FastAPI + SQLAlchemy example <https://python-dependency-injector.ets-labs.org/examples/fastapi-sqlalchemy.html>`_
Tutorials
---------

View File

@ -0,0 +1,119 @@
.. _fastapi-sqlalchemy-example:
FastAPI + SQLAlchemy example
============================
.. meta::
:keywords: Python,Dependency Injection,FastAPI,SQLAlchemy,Example
:description: This example demonstrates a usage of the FastAPI, SQLAlchemy, and Dependency Injector.
This example shows how to use ``Dependency Injector`` with `FastAPI <https://fastapi.tiangolo.com/>`_ and
`SQLAlchemy <https://www.sqlalchemy.org/>`_.
The source code is available on the `Github <https://github.com/ets-labs/python-dependency-injector/tree/master/examples/miniapps/fastapi-sqlalchemy>`_.
Thanks to `@ShvetsovYura <https://github.com/ShvetsovYura>`_ for providing initial example:
`FastAPI_DI_SqlAlchemy <https://github.com/ShvetsovYura/FastAPI_DI_SqlAlchemy>`_.
Application structure
---------------------
Application has next structure:
.. code-block:: bash
./
├── webapp/
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ ├── database.py
│ ├── endpoints.py
│ ├── models.py
│ ├── repositories.py
│ ├── services.py
│ └── tests.py
├── config.yml
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
Application factory
-------------------
Application factory creates container, wires it with the ``endpoints`` module, creates
``FastAPI`` app, and setup routes.
Application factory also creates database if it does not exist.
Listing of ``webapp/application.py``:
.. literalinclude:: ../../examples/miniapps/fastapi-sqlalchemy/webapp/application.py
:language: python
Endpoints
---------
Module ``endpoints`` contains example endpoints. Endpoints have a dependency on user service.
User service is injected using :ref:`wiring` feature. See ``webapp/endpoints.py``:
.. literalinclude:: ../../examples/miniapps/fastapi-sqlalchemy/webapp/endpoints.py
:language: python
Container
---------
Declarative container wires example user service, user repository, and utility database class.
See ``webapp/containers.py``:
.. literalinclude:: ../../examples/miniapps/fastapi-sqlalchemy/webapp/containers.py
:language: python
Services
--------
Module ``services`` contains example user service. See ``webapp/services.py``:
.. literalinclude:: ../../examples/miniapps/fastapi-sqlalchemy/webapp/services.py
:language: python
Repositories
------------
Module ``repositories`` contains example user repository. See ``webapp/repositories.py``:
.. literalinclude:: ../../examples/miniapps/fastapi-sqlalchemy/webapp/repositories.py
:language: python
Models
------
Module ``models`` contains example SQLAlchemy user model. See ``webapp/models.py``:
.. literalinclude:: ../../examples/miniapps/fastapi-sqlalchemy/webapp/models.py
:language: python
Database
-----
Module ``database`` defines declarative base and utility class with engine and session factory.
See ``webapp/database.py``:
.. literalinclude:: ../../examples/miniapps/fastapi-sqlalchemy/webapp/database.py
:language: python
Tests
-----
Tests use :ref:`provider-overriding` feature to replace repository with a mock. See ``webapp/tests.py``:
.. literalinclude:: ../../examples/miniapps/fastapi-sqlalchemy/webapp/tests.py
:language: python
:emphasize-lines: 25, 45, 58, 74, 86, 97
Sources
-------
The source code is available on the `Github <https://github.com/ets-labs/python-dependency-injector/tree/master/examples/miniapps/fastapi-sqlalchemy>`_.
.. disqus::

View File

@ -20,5 +20,6 @@ Explore the examples to see the ``Dependency Injector`` in action.
sanic
fastapi
fastapi-redis
fastapi-sqlalchemy
.. disqus::

View File

@ -288,6 +288,7 @@ Choose one of the following as a next step:
- :ref:`sanic-example`
- :ref:`fastapi-example`
- :ref:`fastapi-redis-example`
- :ref:`fastapi-sqlalchemy-example`
- Pass the tutorials:
- :ref:`flask-tutorial`
- :ref:`aiohttp-tutorial`

View File

@ -7,6 +7,12 @@ that were made in every particular version.
From version 0.7.6 *Dependency Injector* framework strictly
follows `Semantic versioning`_
Development version
-------------------
- Add ``FastAPI`` + ``SQLAlchemy`` example.
Thanks to `@ShvetsovYura <https://github.com/ShvetsovYura>`_ for providing initial example:
`FastAPI_DI_SqlAlchemy <https://github.com/ShvetsovYura/FastAPI_DI_SqlAlchemy>`_.
4.16.0
------
- Add container base class ``containers.Container``. ``DynamicContainer``

View File

@ -336,5 +336,6 @@ Take a look at other application examples:
- :ref:`sanic-example`
- :ref:`fastapi-example`
- :ref:`fastapi-redis-example`
- :ref:`fastapi-sqlalchemy-example`
.. disqus::

View File

@ -0,0 +1,13 @@
FROM python:3.9-buster
ENV PYTHONUNBUFFERED=1
ENV HOST=0.0.0.0
ENV PORT=8000
WORKDIR /code
COPY . /code/
RUN pip install --upgrade pip \
&& pip install -r requirements.txt
CMD uvicorn webapp.application:app --host ${HOST} --port ${PORT}

View File

@ -0,0 +1,96 @@
FastAPI + SQLAlchemy + Dependency Injector Example
==================================================
This is a `FastAPI <https://fastapi.tiangolo.com/>`_ +
`SQLAlchemy <https://www.sqlalchemy.org/>`_ +
`Dependency Injector <https://python-dependency-injector.ets-labs.org/>`_ example application.
Thanks to `@ShvetsovYura <https://github.com/ShvetsovYura>`_ for providing initial example:
`FastAPI_DI_SqlAlchemy <https://github.com/ShvetsovYura/FastAPI_DI_SqlAlchemy>`_.
Run
---
Build the Docker image:
.. code-block:: bash
docker-compose build
Run the docker-compose environment:
.. code-block:: bash
docker-compose up
The output should be something like:
.. code-block::
Starting fastapi-sqlalchemy_webapp_1 ... done
Attaching to fastapi-sqlalchemy_webapp_1
webapp_1 | 2021-02-04 22:07:19,804 INFO sqlalchemy.engine.base.Engine SELECT CAST('test plain returns' AS VARCHAR(60)) AS anon_1
webapp_1 | 2021-02-04 22:07:19,804 INFO sqlalchemy.engine.base.Engine ()
webapp_1 | 2021-02-04 22:07:19,804 INFO sqlalchemy.engine.base.Engine SELECT CAST('test unicode returns' AS VARCHAR(60)) AS anon_1
webapp_1 | 2021-02-04 22:07:19,804 INFO sqlalchemy.engine.base.Engine ()
webapp_1 | 2021-02-04 22:07:19,805 INFO sqlalchemy.engine.base.Engine PRAGMA main.table_info("users")
webapp_1 | 2021-02-04 22:07:19,805 INFO sqlalchemy.engine.base.Engine ()
webapp_1 | 2021-02-04 22:07:19,808 INFO sqlalchemy.engine.base.Engine PRAGMA temp.table_info("users")
webapp_1 | 2021-02-04 22:07:19,808 INFO sqlalchemy.engine.base.Engine ()
webapp_1 | 2021-02-04 22:07:19,809 INFO sqlalchemy.engine.base.Engine
webapp_1 | CREATE TABLE users (
webapp_1 | id INTEGER NOT NULL,
webapp_1 | email VARCHAR,
webapp_1 | hashed_password VARCHAR,
webapp_1 | is_active BOOLEAN,
webapp_1 | PRIMARY KEY (id),
webapp_1 | UNIQUE (email),
webapp_1 | CHECK (is_active IN (0, 1))
webapp_1 | )
webapp_1 |
webapp_1 |
webapp_1 | 2021-02-04 22:07:19,810 INFO sqlalchemy.engine.base.Engine ()
webapp_1 | 2021-02-04 22:07:19,821 INFO sqlalchemy.engine.base.Engine COMMIT
webapp_1 | INFO: Started server process [8]
webapp_1 | INFO: Waiting for application startup.
webapp_1 | INFO: Application startup complete.
webapp_1 | INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
After that visit http://127.0.0.1:8000/docs in your browser.
Test
----
This application comes with the unit tests.
To run the tests do:
.. code-block:: bash
docker-compose run --rm webapp py.test webapp/tests.py --cov=webapp
The output should be something like:
.. code-block::
platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /code
plugins: cov-2.11.1
collected 7 items
webapp/tests.py ....... [100%]
----------- coverage: platform linux, python 3.9.1-final-0 -----------
Name Stmts Miss Cover
--------------------------------------------
webapp/__init__.py 0 0 100%
webapp/application.py 14 0 100%
webapp/containers.py 9 0 100%
webapp/database.py 24 8 67%
webapp/endpoints.py 32 0 100%
webapp/models.py 10 1 90%
webapp/repositories.py 36 20 44%
webapp/services.py 16 0 100%
webapp/tests.py 59 0 100%
--------------------------------------------
TOTAL 200 29 86%

View File

@ -0,0 +1,2 @@
db:
url: "sqlite:///./webapp.db"

View File

@ -0,0 +1,11 @@
version: "3.7"
services:
webapp:
build: ./
image: webapp
ports:
- "8000:8000"
volumes:
- "./:/code"

View File

@ -0,0 +1,8 @@
dependency-injector
fastapi
uvicorn
pyyaml
sqlalchemy
pytest
requests
pytest-cov

View File

@ -0,0 +1 @@
"""Top-level package."""

View File

@ -0,0 +1,23 @@
"""Application module."""
from fastapi import FastAPI
from .containers import Container
from . import endpoints
def create_app() -> FastAPI:
container = Container()
container.config.from_yaml('config.yml')
container.wire(modules=[endpoints])
db = container.db()
db.create_database()
app = FastAPI()
app.container = container
app.include_router(endpoints.router)
return app
app = create_app()

View File

@ -0,0 +1,24 @@
"""Containers module."""
from dependency_injector import containers, providers
from .database import Database
from .repositories import UserRepository
from .services import UserService
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
db = providers.Singleton(Database, db_url=config.db.url)
user_repository = providers.Factory(
UserRepository,
session_factory=db.provided.session,
)
user_service = providers.Factory(
UserService,
user_repository=user_repository,
)

View File

@ -0,0 +1,41 @@
"""Database module."""
from contextlib import contextmanager, AbstractContextManager
from typing import Callable
import logging
from sqlalchemy import create_engine, orm
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
Base = declarative_base()
class Database:
def __init__(self, db_url: str) -> None:
self._engine = create_engine(db_url, echo=True)
self._session_factory = orm.scoped_session(
orm.sessionmaker(
autocommit=False,
autoflush=False,
bind=self._engine,
),
)
def create_database(self) -> None:
Base.metadata.create_all(self._engine)
@contextmanager
def session(self) -> Callable[..., AbstractContextManager[Session]]:
session: Session = self._session_factory()
try:
yield session
except Exception:
logger.exception('Session rollback because of exception')
session.rollback()
raise
finally:
session.close()

View File

@ -0,0 +1,57 @@
"""Endpoints module."""
from fastapi import APIRouter, Depends, Response, status
from dependency_injector.wiring import inject, Provide
from .containers import Container
from .services import UserService
from .repositories import NotFoundError
router = APIRouter()
@router.get('/users')
@inject
def get_list(
user_service: UserService = Depends(Provide[Container.user_service]),
):
return user_service.get_users()
@router.get('/users/{user_id}')
@inject
def get_by_id(
user_id: int,
user_service: UserService = Depends(Provide[Container.user_service]),
):
try:
return user_service.get_user_by_id(user_id)
except NotFoundError:
return Response(status_code=status.HTTP_404_NOT_FOUND)
@router.post('/users', status_code=status.HTTP_201_CREATED)
@inject
def add(
user_service: UserService = Depends(Provide[Container.user_service]),
):
return user_service.create_user()
@router.delete('/users/{user_id}', status_code=status.HTTP_204_NO_CONTENT)
@inject
def remove(
user_id: int,
user_service: UserService = Depends(Provide[Container.user_service]),
):
try:
user_service.delete_user_by_id(user_id)
except NotFoundError:
return Response(status_code=status.HTTP_404_NOT_FOUND)
else:
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get('/status')
def get_status():
return {'status': 'OK'}

View File

@ -0,0 +1,21 @@
"""Models module."""
from sqlalchemy import Column, String, Boolean, Integer
from .database import Base
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
email = Column(String, unique=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
def __repr__(self):
return f'<User(id="{self.id}", ' \
f'email="{self.email}", ' \
f'hashed_password="{self.hashed_password}", ' \
f'is_active="{self.is_active}")>'

View File

@ -0,0 +1,54 @@
"""Repositories module."""
from contextlib import AbstractContextManager
from typing import Callable, Iterator
from sqlalchemy.orm import Session
from .models import User
class UserRepository:
def __init__(self, session_factory: Callable[..., AbstractContextManager[Session]]) -> None:
self.session_factory = session_factory
def get_all(self) -> Iterator[User]:
with self.session_factory() as session:
return session.query(User).all()
def get_by_id(self, user_id: int) -> User:
with self.session_factory() as session:
user = session.query(User).filter(User.id == user_id).first()
if not user:
raise UserNotFoundError(user_id)
return user
def add(self, email: str, password: str, is_active: bool = True) -> User:
with self.session_factory() as session:
user = User(email=email, hashed_password=password, is_active=is_active)
session.add(user)
session.commit()
session.refresh(user)
return user
def delete_by_id(self, user_id: int) -> None:
with self.session_factory() as session:
entity: User = session.query(User).filter(User.id == user_id).first()
if not entity:
raise UserNotFoundError(user_id)
session.delete(entity)
session.commit()
class NotFoundError(Exception):
entity_name: str
def __init__(self, entity_id):
super().__init__(f'{self.entity_name} not found, id: {entity_id}')
class UserNotFoundError(NotFoundError):
entity_name: str = 'User'

View File

@ -0,0 +1,26 @@
"""Services module."""
from uuid import uuid4
from typing import Iterator
from .repositories import UserRepository
from .models import User
class UserService:
def __init__(self, user_repository: UserRepository) -> None:
self._repository: UserRepository = user_repository
def get_users(self) -> Iterator[User]:
return self._repository.get_all()
def get_user_by_id(self, user_id: int) -> User:
return self._repository.get_by_id(user_id)
def create_user(self) -> User:
uid = uuid4()
return self._repository.add(email=f'{uid}@email.com', password='pwd')
def delete_user_by_id(self, user_id: int) -> None:
return self._repository.delete_by_id(user_id)

View File

@ -0,0 +1,107 @@
"""Tests module."""
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from .repositories import UserRepository, UserNotFoundError
from .models import User
from .application import app
@pytest.fixture
def client():
yield TestClient(app)
def test_get_list(client):
repository_mock = mock.Mock(spec=UserRepository)
repository_mock.get_all.return_value = [
User(id=1, email='test1@email.com', hashed_password='pwd', is_active=True),
User(id=2, email='test2@email.com', hashed_password='pwd', is_active=False),
]
with app.container.user_repository.override(repository_mock):
response = client.get('/users')
assert response.status_code == 200
data = response.json()
assert data == [
{'id': 1, 'email': 'test1@email.com', 'hashed_password': 'pwd', 'is_active': True},
{'id': 2, 'email': 'test2@email.com', 'hashed_password': 'pwd', 'is_active': False},
]
def test_get_by_id(client):
repository_mock = mock.Mock(spec=UserRepository)
repository_mock.get_by_id.return_value = User(
id=1,
email='xyz@email.com',
hashed_password='pwd',
is_active=True,
)
with app.container.user_repository.override(repository_mock):
response = client.get('/users/1')
assert response.status_code == 200
data = response.json()
assert data == {'id': 1, 'email': 'xyz@email.com', 'hashed_password': 'pwd', 'is_active': True}
repository_mock.get_by_id.assert_called_once_with(1)
def test_get_by_id_404(client):
repository_mock = mock.Mock(spec=UserRepository)
repository_mock.get_by_id.side_effect = UserNotFoundError(1)
with app.container.user_repository.override(repository_mock):
response = client.get('/users/1')
assert response.status_code == 404
@mock.patch('webapp.services.uuid4', return_value='xyz')
def test_add(_, client):
repository_mock = mock.Mock(spec=UserRepository)
repository_mock.add.return_value = User(
id=1,
email='xyz@email.com',
hashed_password='pwd',
is_active=True,
)
with app.container.user_repository.override(repository_mock):
response = client.post('/users')
assert response.status_code == 201
data = response.json()
assert data == {'id': 1, 'email': 'xyz@email.com', 'hashed_password': 'pwd', 'is_active': True}
repository_mock.add.assert_called_once_with(email='xyz@email.com', password='pwd')
def test_remove(client):
repository_mock = mock.Mock(spec=UserRepository)
with app.container.user_repository.override(repository_mock):
response = client.delete('/users/1')
assert response.status_code == 204
repository_mock.delete_by_id.assert_called_once_with(1)
def test_remove_404(client):
repository_mock = mock.Mock(spec=UserRepository)
repository_mock.delete_by_id.side_effect = UserNotFoundError(1)
with app.container.user_repository.override(repository_mock):
response = client.delete('/users/1')
assert response.status_code == 404
def test_status(client):
response = client.get('/status')
assert response.status_code == 200
data = response.json()
assert data == {'status': 'OK'}

View File

@ -1,7 +1,7 @@
FastAPI + Dependency Injector Example
=====================================
This is an `FastAPI <https://fastapi.tiangolo.com/>`_ +
This is a `FastAPI <https://fastapi.tiangolo.com/>`_ +
`Dependency Injector <https://python-dependency-injector.ets-labs.org/>`_ example application.
The example application is a REST API that searches for funny GIFs on the `Giphy <https://giphy.com/>`_.