├── .gitignore
├── LICENSE
├── README.md
└── src
├── docker-compose.yml
└── fastapi_server
├── Dockerfile
├── __init__.py
├── alembic.ini
├── app
├── __init__.py
├── api
│ ├── __init__.py
│ ├── dependencies
│ │ ├── __init__.py
│ │ ├── database.py
│ │ └── repository.py
│ └── routes
│ │ ├── __init__.py
│ │ ├── children.py
│ │ ├── parents.py
│ │ └── router.py
├── core
│ ├── __init__.py
│ ├── app_settings.py
│ ├── config.py
│ ├── logging.py
│ └── tags_metadata.py
├── db
│ ├── __init__.py
│ ├── db_session.py
│ ├── models
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── children.py
│ │ ├── metadata.py
│ │ └── parents.py
│ └── repositories
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── children.py
│ │ └── parents.py
├── fastapi_server.py
└── models
│ ├── __init__.py
│ ├── base.py
│ ├── domain
│ ├── __init__.py
│ ├── children.py
│ └── parents.py
│ └── utility_schemas
│ ├── __init__.py
│ ├── children.py
│ └── parents.py
├── migrations
├── README
├── env.py
├── script.py.mako
└── versions
│ ├── 98be1d14f054_the_hottest_new_db_changes_around.py
│ └── d30ab2ee6175_the_hottest_new_db_changes_around.py
├── pytest.ini
├── requirements.txt
└── tests
├── __init__.py
├── conftest.py
├── fixtures
├── __init__.py
├── children.py
└── parents.py
└── unit_tests
├── __init__.py
└── test_api
├── __init__.py
└── test_routes
├── __init__.py
├── test_main.py
└── test_parents.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
161 |
162 | # Project
163 | pgadmin-data
164 | postgres-data
165 | prod.env
166 | test.env
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Lukas Reinhardt
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Async Web REST API with FastAPI + SQLAlchemy 2.0 Postgres ORM + Docker + Pytest + Alembic
2 |
3 | This is a template for a simple Web REST API using FastAPI with an async Postgres database.
4 | Using docker-compose to hook up the database and mounting the
5 | postgres data to my local machine makes playing around with the example easier for me.
6 |
7 | Communication to the postgres database is done using SQLAlchemy 2.0 ORM style and async
8 | database access via asyncpg.
9 | To see what the state of the database is during development, pgAdmin is included
10 | to get a nice GUI for database interaction.
11 |
12 | This repo also includes a pytest testing setup applying the sqlalchemy test suite example
13 | to async.
14 |
15 | This is a hobby project I use to learn about the awesome FastaAPI project, SQLAlchemy and building REST APIs.
16 | If you have any questions, suggestions or ideas regarding my code or project structure
17 | feel free to contact me or contribute.
18 |
19 | Happy coding :rocket:
20 |
21 | ## Getting Started
22 |
23 | ### Dependencies
24 | * Docker Engine - https://docs.docker.com/engine/install/
25 | * Docker Compose - https://docs.docker.com/compose/install/
26 |
27 | ### Installing
28 | Before starting, make sure you have the latest versions of Docker installed.
29 |
30 | Run the following commands to pull this repo from github and get to src folder:
31 | ```
32 | git clone https://github.com/reinhud/fastapi_postgres_template
33 | cd POSTGRES_TEST_CONTAINER_PORT/src
34 | ```
35 | Create the ```.env``` files or modify the ```.env.example``` files:
36 | ```
37 | touch .env
38 | echo POSTGRES_CONTAINER_PORT=5432
39 | echo POSTGRES_TEST_CONTAINER_PORT=6543
40 | ```
41 | ```
42 | touch .env
43 | echo POSTGRES_USER="postgres"
44 | echo POSTGRES_PASSWORD="postgres"
45 | echo POSTGRES_SERVER="postgres_container"
46 | echo POSTGRES_PORT=5432
47 | echo POSTGRES_DB="postgres"
48 | echo PGADMIN_DEFAULT_EMAIL="pgadmin4@pgadmin.org"
49 | echo PGADMIN_DEFAULT_PASSWORD="postgres"
50 | echo PGADMIN_LISTEN_PORT=80
51 | ```
52 |
53 | ### Run With Docker
54 | You must have ```docker``` and ```docker-compose``` tools installed to work with material in this section.
55 | Head to the ```/src``` folder of the project.
56 | To run the program, we spin up the containers with
57 | ```
58 | docker-compose up
59 | ```
60 | If this is the first time bringing up the project, you need to build the images first:
61 | ```
62 | docker-compose up --build
63 | ```
64 |
65 | ### pgAdmin
66 | You can interact with the running database with ```pgAdmin``` .
67 | Go to your browser and navigate to:
68 | ```
69 | http://localhost:5050/login
70 | ```
71 | Now you can log into ```pgAdmin``` with the credentials
72 | set in the ```.env```.
73 | Initially you will have to register the ```postgres_container``` server
74 | and connect to it.
75 |
76 | ### Applying Database Migrations
77 | In testing, newest revision will be applied automatically before test runs.
78 | To run migrations manually before spinning up the docker containers, go to ```/src``` and:
79 | * Create new revision:
80 | ```
81 | docker-compose run fastapi_server alembic revision --autogenerate -m "The hottest new db changes around"
82 | ```
83 | This will try to capture the newest changes automatically.
84 | Check that the changes were correctly mapped by looking into
85 | the revision file in ```/fastapi_server/migrations/versions```.
86 | Revisions can be created manually to if needed.
87 | * Apply migrations:
88 | ```
89 | docker-compose run fastapi_server alembic upgrade head
90 | ```
91 | If you get an error like *Target database is not up to date.* you might have to
92 | manually tell alembic that the current migration represents the state of the database
93 | with ```docker-compose run fastapi_server alembic stamp head``` before upgrading again.
94 |
95 | ### Testing
96 | Head to ```/src``` folder and run:
97 | ```
98 | docker-compose run fastapi_server pytest .
99 | ```
100 |
101 | ### Web Routes & Documentation
102 | All routes are available on ```/docs``` or ```/redoc``` paths with Swagger or Redoc.
103 | In your browser, navigate to
104 | ```
105 | http://127.0.0.1:8000/docs
106 | ```
107 | to get to the ```SwaggerUI``` API documentation.
108 | This is a great place to try out all the routes manually.
109 |
110 | ### Project Structure
111 | ```bash
112 | ├───app
113 | │ ├───api
114 | │ │ ├───dependencies # FastAPI dependency injection
115 | │ │ └───routes # endpoint definintions
116 | │ ├───core # settings
117 | │ ├───db
118 | │ │ ├───models # SQLAlchemy models
119 | │ │ └───repositories # CRUD related stuff
120 | │ ├───models
121 | │ │ ├───domain # schemas related to domain entities
122 | │ │ └───utility_schemas # schemas for other validation
123 | │ └───services # not just CRUD related stuff
124 | ├───migrations
125 | │ └───versions
126 | └───tests
127 | ├───fixtures # where test specific fixtures live
128 | └───unit_tests
129 | └───test_api # testing endpoints
130 | ```
131 |
132 | ## Authors
133 |
134 | @Lukas Reinhardt
135 | ## License
136 |
137 | This project is licensed under the MIT License - see the LICENSE file for details
138 |
139 | ## Acknowledgments
140 | Inspiration, usefull repos, code snippets, etc.
141 | * FastAPI Realworl Example - https://github.com/nsidnev/fastapi-realworld-example-app/blob/master/README.rst
142 | * Phresh FastAPI Tutorial Series - https://github.com/Jastor11/phresh-tutorial/tree/master
143 | * Example by rhoboro - https://github.com/rhoboro/async-fastapi-sqlalchemy
144 | * SQLAlchemy async test suite - https://github.com/sqlalchemy/sqlalchemy/issues/5811
145 | * Unify Python/ Uvicorn Logging - https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/
146 |
147 |
--------------------------------------------------------------------------------
/src/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | db:
5 | container_name: postgres_container
6 | hostname: postgres_container
7 | image: postgres:14
8 | restart: always
9 | ports:
10 | - "${POSTGRES_CONTAINER_PORT}:5432"
11 | volumes: # dont use this in production, mounting docker volume to location on my machine where to store postgres data for docker
12 | - ./docker_pg_data/postgres-data:/var/lib/postgresql/data
13 | env_file:
14 | - .env
15 | networks:
16 | app1_net:
17 | ipv4_address: 192.168.0.2
18 |
19 |
20 | pgadmin:
21 | container_name: pgadmin4_container
22 | image: dpage/pgadmin4
23 | restart: always
24 | ports:
25 | - "5050:80"
26 | volumes:
27 | - ./docker_pg_data/pgadmin-data:/var/lib/pgadmin
28 | env_file:
29 | - .env
30 | depends_on:
31 | - db
32 | networks:
33 | app1_net:
34 | ipv4_address: 192.168.0.3
35 |
36 |
37 | fastapi_server:
38 | container_name: fastapi_server_container
39 | build:
40 | context: ./fastapi_server
41 | dockerfile: Dockerfile
42 | working_dir: /fastapi_server
43 | ports:
44 | - "8000:8000"
45 | command: bash -c "uvicorn app.fastapi_server:app --reload --workers 1 --host 0.0.0.0 --port 8000 --log-level debug"
46 | volumes:
47 | - ./fastapi_server:/fastapi_server # used for live reloading of container to changes in app on local machine
48 | env_file:
49 | - .env
50 | depends_on:
51 | - db
52 | networks:
53 | app1_net:
54 | ipv4_address: 192.168.0.4
55 |
56 | volumes:
57 | pgadmin-data:
58 |
59 | networks:
60 | app1_net:
61 | ipam:
62 | driver: default
63 | config:
64 | - subnet: "192.168.0.0/24" #ipv4
65 | gateway: 192.168.0.1
--------------------------------------------------------------------------------
/src/fastapi_server/Dockerfile:
--------------------------------------------------------------------------------
1 | # base image
2 | FROM python:3.10.5-slim-buster
3 |
4 | # add a work directory
5 | WORKDIR /fastapi_server
6 |
7 | # env
8 | ENV PYTHONDONTWRITEBYTECODE 1
9 | ENV PYTHONBUFFERED 1
10 |
11 | # install system dependencies
12 | RUN apt-get update \
13 | && apt-get -y install netcat gcc postgresql \
14 | && apt-get clean
15 |
16 | # install python dependencies
17 | RUN pip install --upgrade pip
18 | COPY ./requirements.txt /fastapi_server
19 | RUN pip install --no-cache-dir -r requirements.txt
20 |
21 | # copy files to the container folder
22 | COPY . /fastapi_server
23 |
24 | # start the server
25 | CMD ["uvicorn", "app.fastapi_server:app", "--host", "0.0.0.0", "--port", "8001"]
--------------------------------------------------------------------------------
/src/fastapi_server/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # path to migration scripts
5 | script_location = migrations
6 |
7 | # template used to generate migration files
8 | # file_template = %%(rev)s_%%(slug)s
9 |
10 | # sys.path path, will be prepended to sys.path if present.
11 | # defaults to the current working directory.
12 | ##prepend_sys_path = .
13 |
14 | # timezone to use when rendering the date within the migration file
15 | # as well as the filename.
16 | # If specified, requires the python-dateutil library that can be
17 | # installed by adding `alembic[tz]` to the pip requirements
18 | # string value is passed to dateutil.tz.gettz()
19 | # leave blank for localtime
20 | # timezone =
21 |
22 | # max length of characters to apply to the
23 | # "slug" field
24 | # truncate_slug_length = 40
25 |
26 | # set to 'true' to run the environment during
27 | # the 'revision' command, regardless of autogenerate
28 | # revision_environment = false
29 |
30 | # set to 'true' to allow .pyc and .pyo files without
31 | # a source .py file to be detected as revisions in the
32 | # versions/ directory
33 | # sourceless = false
34 |
35 | # version location specification; This defaults
36 | # to migrations/versions. When using multiple version
37 | # directories, initial revisions must be specified with --version-path.
38 | # The path separator used here should be the separator specified by "version_path_separator" below.
39 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
40 |
41 | # version path separator; As mentioned above, this is the character used to split
42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
44 | # Valid values for version_path_separator are:
45 | #
46 | # version_path_separator = :
47 | # version_path_separator = ;
48 | # version_path_separator = space
49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
50 |
51 | # the output encoding used when revision files
52 | # are written from script.py.mako
53 | # output_encoding = utf-8
54 |
55 | # sqlalchemy.url = driver://user:pass@localhost/dbname
56 |
57 |
58 | [post_write_hooks]
59 | # post_write_hooks defines scripts or Python functions that are run
60 | # on newly generated revision scripts. See the documentation for further
61 | # detail and examples
62 |
63 | # format using "black" - use the console_scripts runner, against the "black" entrypoint
64 | # hooks = black
65 | # black.type = console_scripts
66 | # black.entrypoint = black
67 | # black.options = -l 79 REVISION_SCRIPT_FILENAME
68 |
69 | # Logging configuration
70 | [loggers]
71 | keys = root,sqlalchemy,alembic
72 |
73 | [handlers]
74 | keys = console
75 |
76 | [formatters]
77 | keys = generic
78 |
79 | [logger_root]
80 | level = WARN
81 | handlers = console
82 | qualname =
83 |
84 | [logger_sqlalchemy]
85 | level = WARN
86 | handlers =
87 | qualname = sqlalchemy.engine
88 |
89 | [logger_alembic]
90 | level = INFO
91 | handlers =
92 | qualname = alembic
93 |
94 | [handler_console]
95 | class = StreamHandler
96 | args = (sys.stderr,)
97 | level = NOTSET
98 | formatter = generic
99 |
100 | [formatter_generic]
101 | format = %(levelname)-5.5s [%(name)s] %(message)s
102 | datefmt = %H:%M:%S
103 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/api/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/api/dependencies/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/api/dependencies/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/api/dependencies/database.py:
--------------------------------------------------------------------------------
1 | """Database ependancies for FastApi app.
2 |
3 | TODO:
4 | 1. do funcs need to be async?
5 | """
6 | from typing import AsyncGenerator
7 |
8 | from loguru import logger
9 | from sqlalchemy.exc import SQLAlchemyError
10 | from sqlalchemy.ext.asyncio import AsyncSession
11 | from sqlalchemy.orm import sessionmaker
12 |
13 | from app.db.db_session import get_async_engine
14 |
15 |
16 | # DB dependency
17 | async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
18 | """Yield an async session.
19 |
20 | All conversations with the database are established via the session
21 | objects. Also. the sessions act as holding zone for ORM-mapped objects.
22 | """
23 | async_session = sessionmaker(
24 | bind=get_async_engine(),
25 | class_=AsyncSession,
26 | autoflush=False,
27 | expire_on_commit=False, # document this
28 | )
29 | async with async_session() as async_sess:
30 | try:
31 |
32 | yield async_sess
33 |
34 | except SQLAlchemyError as e:
35 | logger.error("Unable to yield session in database dependency")
36 | logger.error(e)
37 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/api/dependencies/repository.py:
--------------------------------------------------------------------------------
1 | """Repository dependancies for FastApi app.
2 |
3 | TODO:
4 | 1. do funcs need to be async?
5 | """
6 | from typing import Callable, Type, TypeVar
7 |
8 | from fastapi import Depends
9 | from sqlalchemy.ext.asyncio import AsyncSession
10 |
11 | from app.api.dependencies.database import get_async_session
12 | from app.db.repositories.base import SQLAlchemyRepository
13 |
14 |
15 | SQLA_REPO_TYPE = TypeVar("SQLA_REPO_TYPE", bound=SQLAlchemyRepository)
16 |
17 |
18 | # Repo dependency
19 | def get_repository(
20 | repo_type: Type[SQLA_REPO_TYPE],
21 | ) -> Callable[[AsyncSession], Type[SQLA_REPO_TYPE]]:
22 | """Returns specified repository seeded with an async database session."""
23 | def get_repo(
24 | db: AsyncSession = Depends(get_async_session),
25 | ) -> Type[SQLA_REPO_TYPE]:
26 | return repo_type(db=db)
27 |
28 | return get_repo
--------------------------------------------------------------------------------
/src/fastapi_server/app/api/routes/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/api/routes/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/api/routes/children.py:
--------------------------------------------------------------------------------
1 | """Endpoints for 'children' ressource."""
2 | from typing import List
3 |
4 | from fastapi import APIRouter, Depends, HTTPException, status
5 | from loguru import logger
6 |
7 | from app.api.dependencies.repository import get_repository
8 | from app.db.repositories.children import ChildRepository
9 | from app.models.domain.children import ChildCreate, ChildInDB
10 | from app.models.utility_schemas.children import ChildOptionalSchema
11 |
12 |
13 | router = APIRouter()
14 |
15 |
16 | # Basic Parent Endpoints
17 | # =========================================================================== #
18 | @router.post("/post", response_model=ChildInDB, name="Children: create-child", status_code=status.HTTP_201_CREATED)
19 | async def post_child(
20 | child_new: ChildCreate,
21 | child_repo: ChildRepository = Depends(get_repository(ChildRepository)),
22 | ) -> ChildInDB:
23 | child_created = await child_repo.create(obj_new=child_new)
24 |
25 | return child_created
26 |
27 | @router.get("/get_by_id", response_model=ChildInDB | None, name="children: read-one-child")
28 | async def get_one_child(
29 | id: int,
30 | child_repo: ChildRepository = Depends(get_repository(ChildRepository)),
31 | ) -> ChildInDB | None:
32 | child_db = await child_repo.read_by_id(id=id)
33 | if not child_db:
34 | logger.warning(f"No child with id = {id}.")
35 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No child with id = {id}.")
36 |
37 | return child_db
38 |
39 | @router.post("/get_optional", response_model=List[ChildInDB] | None, name="children: read-optional-children")
40 | async def get_optional_children(
41 | query_schema: ChildOptionalSchema,
42 | child_repo: ChildRepository = Depends(get_repository(ChildRepository)),
43 | ) -> List[ChildInDB] | None:
44 | children_db = await child_repo.read_optional(query_schema=query_schema)
45 | if not children_db:
46 | logger.warning(f"No children found.")
47 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No children matching query: {query_schema.dict(exclude_none=True)}.")
48 |
49 | return children_db
50 |
51 | @router.delete("/delete", response_model=ChildInDB, name="children: delete-child")
52 | async def delete_child(
53 | id: int,
54 | child_repo: ChildRepository = Depends(get_repository(ChildRepository)),
55 | ) -> ChildInDB:
56 | parent_deleted = await child_repo.delete(id=id)
57 | if not parent_deleted:
58 | logger.warning(f"No parent with id = {id}.")
59 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Unable to delete child with id = {id}, Parent not found")
60 |
61 | return parent_deleted
--------------------------------------------------------------------------------
/src/fastapi_server/app/api/routes/parents.py:
--------------------------------------------------------------------------------
1 | """Endpoints for 'parent' ressource."""
2 | from typing import List
3 |
4 | from fastapi import APIRouter, Depends, HTTPException, status
5 | from loguru import logger
6 |
7 | from app.api.dependencies.repository import get_repository
8 | from app.db.repositories.parents import ParentRepository
9 | from app.models.domain.parents import ParentCreate, ParentInDB
10 | from app.models.domain.children import ChildInDB
11 | from app.models.utility_schemas.parents import ParentOptionalSchema
12 |
13 |
14 | router = APIRouter()
15 |
16 |
17 | # Basic Parent Endpoints
18 | # =========================================================================== #
19 | @router.post("/post", response_model=ParentInDB, name="parents: create-parent", status_code=status.HTTP_201_CREATED)
20 | async def post_parent(
21 | parent_new: ParentCreate,
22 | parent_repo: ParentRepository = Depends(get_repository(ParentRepository)),
23 | ) -> ParentInDB:
24 | parent_created = await parent_repo.create(obj_new=parent_new)
25 |
26 | return parent_created
27 |
28 | @router.get("/get_by_id", response_model=ParentInDB | None, name="parents: read-one-parent")
29 | async def get_one_parent(
30 | id: int,
31 | parent_repo: ParentRepository = Depends(get_repository(ParentRepository)),
32 | ) -> ParentInDB | None:
33 | parent_db = await parent_repo.read_by_id(id=id)
34 | if not parent_db:
35 | logger.warning(f"No parent with id = {id}.")
36 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No parent with id = {id}.")
37 |
38 | return parent_db
39 |
40 | @router.post("/get_optional", name="parents: read-optional-parents") #, response_model=List[ParentInDB] | None
41 | async def get_optional_parents(
42 | query_schema: ParentOptionalSchema,
43 | parent_repo: ParentRepository = Depends(get_repository(ParentRepository)),
44 | ) -> List[ParentInDB] | None:
45 | parents_db = await parent_repo.read_optional(query_schema=query_schema)
46 | if not parents_db:
47 | logger.warning(f"No Pprents found.")
48 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No parents matching query: {query_schema.dict(exclude_none=True)}.")
49 |
50 | return parents_db
51 |
52 | @router.delete("/delete", response_model=ParentInDB, name="parents: delete-parent")
53 | async def delete_parent(
54 | id: int,
55 | parent_repo: ParentRepository = Depends(get_repository(ParentRepository)),
56 | ) -> ParentInDB:
57 | parent_deleted = await parent_repo.delete(id=id)
58 | if not parent_deleted:
59 | logger.warning(f"No parent with id = {id}.")
60 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Unable to delete parent with id = {id}, Parent not found")
61 |
62 | return parent_deleted
63 |
64 |
65 |
66 | # Basic relationship pattern endpoint
67 | # =========================================================================== #
68 | @router.get("/get_children", name="parents: get-all-children-for-parent") #response_model=List[ChildInDB]
69 | async def get_parent_children(
70 | id: int,
71 | parent_repo: ParentRepository = Depends(get_repository(ParentRepository)),
72 | ) -> List[ChildInDB] | None:
73 | children = await parent_repo.get_parent_children_by_id(id=id)
74 | if children is None:
75 | logger.info(f"Parent with id: {id} not found.")
76 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Parent with id: {id} not found.")
77 | elif not children:
78 | logger.info(f"Parent with id: {id} has no children.")
79 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No children found for parent with with id: {id}.")
80 | return children
--------------------------------------------------------------------------------
/src/fastapi_server/app/api/routes/router.py:
--------------------------------------------------------------------------------
1 | """Bundling of endpoint routers.
2 |
3 | Import and add all endpoint routers here.
4 | """
5 | from fastapi import APIRouter
6 |
7 | from app.api.routes import children, parents
8 | from app.core.tags_metadata import parents_tag, children_tag
9 |
10 |
11 | router = APIRouter()
12 |
13 | router.include_router(children.router, prefix="/children", tags=[children_tag.name])
14 | router.include_router(parents.router, prefix="/parents", tags=[parents_tag.name])
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/core/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/core/app_settings.py:
--------------------------------------------------------------------------------
1 | """Settings that will be used throughout the application."""
2 | import logging
3 | import os
4 | import sys
5 | from typing import Any, Dict, List, Tuple
6 |
7 | from loguru import logger
8 | from pydantic import PostgresDsn
9 |
10 | from app.core.logging import format_record, InterceptHandler
11 | from app.core.tags_metadata import metadata_tags
12 |
13 |
14 | class AppSettings():
15 | """Bundle all app settings."""
16 | app_env: str = os.getenv("APP_ENV")
17 |
18 | # FastAPI App settings
19 | debug: bool = False
20 | docs_url: str = "/docs"
21 | openapi_prefix: str = ""
22 | openapi_url: str = "/openapi.json"
23 | redoc_url: str = "/redoc"
24 | openapi_tags: List[dict] = [tag.dict(by_alias=True) for tag in metadata_tags]
25 | allowed_hosts: List[str] = ["*"]
26 |
27 | title: str = os.getenv("APP_TITLE")
28 | version: str = os.getenv("APP_VERSION")
29 | description: str = os.getenv("APP_DESCRIPTION")
30 | api_prefix: str = "/api"
31 |
32 | # database settings
33 | postgres_driver: str = "asyncpg"
34 | postgres_user: str = os.getenv("POSTGRES_USER")
35 | postgres_password: str = os.getenv("POSTGRES_PASSWORD")
36 | postgres_server: str = os.getenv("POSTGRES_SERVER")
37 | postgres_port: int = os.getenv("POSTGRES_PORT")
38 | postgres_db: str = os.getenv("POSTGRES_DB")
39 |
40 | # logging
41 | logging_level: int = logging.DEBUG
42 | loggers: Tuple[str, str] = ("uvicorn.asgi", "uvicorn.access")
43 |
44 |
45 | @property
46 | def fastapi_kwargs(self) -> Dict[str, Any]:
47 | return {
48 | "debug": self.debug,
49 | "docs_url": self.docs_url,
50 | "openapi_prefix": self.openapi_prefix,
51 | "openapi_url": self.openapi_url,
52 | "redoc_url": self.redoc_url,
53 | "title": self.title,
54 | "version": self.version,
55 | "description": self.description,
56 | }
57 |
58 | @property
59 | def database_settings(self) -> Dict[str, Any]:
60 | return {
61 | "postgres_user": self.postgres_user,
62 | "postgres_password": self.postgres_password,
63 | "postgres_server": self.postgres_server,
64 | "postgres_port": self.postgres_port,
65 | "postgres_db": self.postgres_db,
66 | }
67 |
68 | @property
69 | def database_url(self) -> PostgresDsn:
70 | """Create a valid Postgres database url."""
71 | return f"postgresql+{self.postgres_driver}://{self.postgres_user}:{self.postgres_password}@{self.postgres_server}:{self.postgres_port}/{self.postgres_db}"
72 |
73 |
74 | def configure_logging(self) -> None:
75 | """Configure and format logging used in app."""
76 | logging.basicConfig()
77 | logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
78 | # intercept everything at the root logger
79 | logging.root.handlers = [InterceptHandler()]
80 | logging.root.setLevel("DEBUG")
81 |
82 | # remove every other logger's handlers
83 | # and propagate to root logger
84 | for name in logging.root.manager.loggerDict.keys():
85 | logging.getLogger(name).handlers = []
86 | logging.getLogger(name).propagate = True
87 |
88 | # configure loguru
89 | logger.configure(handlers=[{"sink": sys.stdout, "serialize": False, "format": format_record, "colorize":True,}])
90 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/core/config.py:
--------------------------------------------------------------------------------
1 | """App configuration functions and access to settings"""
2 | from fastapi import FastAPI
3 | from app.core.app_settings import AppSettings
4 |
5 |
6 | def get_app_settings() -> AppSettings:
7 | return AppSettings()
8 |
9 |
10 | def add_middleware(app: FastAPI) -> None:
11 | """Function to implement middleware.
12 |
13 | Not implemented yet.
14 | """
15 | pass
16 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/core/logging.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | import logging
4 | from types import FrameType
5 | from typing import cast
6 |
7 | from loguru import logger
8 |
9 | from pprint import pformat
10 |
11 |
12 | class InterceptHandler(logging.Handler):
13 | def emit(self, record: logging.LogRecord) -> None: # pragma: no cover
14 | # Get corresponding Loguru level if it exists
15 | try:
16 | level = logger.level(record.levelname).name
17 | except ValueError:
18 | level = str(record.levelno)
19 |
20 | # Find caller from where originated the logged message
21 | frame, depth = sys._getframe(6), 6
22 | while frame.f_code.co_filename == logging.__file__: # noqa: WPS609
23 | frame = cast(FrameType, frame.f_back)
24 | depth += 1
25 |
26 | logger.opt(depth=depth, exception=record.exc_info).log(
27 | level,
28 | record.getMessage(),
29 | )
30 |
31 |
32 | def format_record(record: dict) -> str:
33 | """
34 | Custom format for loguru loggers.
35 | Uses pformat for log any data like request/response body during debug.
36 | Works with logging if loguru handler it.
37 | Example:
38 | >>> payload = [{"users":[{"name": "Nick", "age": 87, "is_active": True}, {"name": "Alex", "age": 27, "is_active": True}], "count": 2}]
39 | >>> logger.bind(payload=).debug("users payload")
40 | >>> [ { 'count': 2,
41 | >>> 'users': [ {'age': 87, 'is_active': True, 'name': 'Nick'},
42 | >>> {'age': 27, 'is_active': True, 'name': 'Alex'}]}]
43 | """
44 | FORMAT = "[{time:YYYY-MM-DD at HH:mm:ss}] [{level}] {message}"
45 | format_string = FORMAT
46 | if record["extra"].get("payload") is not None:
47 | record["extra"]["payload"] = pformat(
48 | record["extra"]["payload"], indent=4, compact=True, width=88
49 | )
50 | format_string += "\n{extra[payload]}"
51 |
52 | format_string += "{exception}\n"
53 | return format_string
--------------------------------------------------------------------------------
/src/fastapi_server/app/core/tags_metadata.py:
--------------------------------------------------------------------------------
1 | """Define metadata for tags used in OpenAPI documentation."""
2 | from typing import Optional
3 |
4 | from app.db.models.base import Base
5 | from app.models.base import BaseSchema
6 |
7 |
8 | ## ===== Tags MetaData Schema ===== ##
9 | class ExternalDocs(BaseSchema):
10 |
11 | description: Optional[str] = None
12 | ulr: str
13 | class MetaDataTag(BaseSchema):
14 |
15 | name: str
16 | description: Optional[str] = None
17 | external_docs: Optional[ExternalDocs] = None
18 |
19 | class COnfig:
20 |
21 | allow_population_by_field_name = True
22 | fields = {"external_docs":{"alias": "externalDocs"}}
23 |
24 |
25 | ## ===== Tags Metadata Definition ===== ##
26 | parents_tag = MetaDataTag(
27 | name="parents",
28 | description="Example description for parent endpoints."
29 | )
30 |
31 | children_tag = MetaDataTag(
32 | name="children",
33 | description="Stuff that you would want to know about this endpoint."
34 | )
35 |
36 |
37 | metadata_tags = [parents_tag, children_tag]
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/db/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/db_session.py:
--------------------------------------------------------------------------------
1 | """Connection to the Postgres database."""
2 | from loguru import logger
3 | from sqlalchemy.exc import SQLAlchemyError
4 | from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
5 |
6 | from app.core.config import get_app_settings
7 | from app.db.models.base import Base
8 |
9 |
10 | def get_async_engine() -> AsyncEngine:
11 | """Return async database engine."""
12 | try:
13 | async_engine: AsyncEngine = create_async_engine(
14 | get_app_settings().database_url,
15 | future=True,
16 | )
17 | except SQLAlchemyError as e:
18 | logger.warning("Unable to establish db engine, database might not exist yet")
19 | logger.warning(e)
20 |
21 | return async_engine
22 |
23 |
24 | async def initialize_database() -> None:
25 | """Create table in metadata if they don't exist yet.
26 |
27 | This uses a sync connection because the 'create_all' doesn't
28 | feature async yet.
29 | """
30 | async_engine = get_async_engine()
31 | async with async_engine.begin() as async_conn:
32 |
33 | await async_conn.run_sync(Base.metadata.create_all)
34 |
35 | logger.success("Initializing database was successfull.")
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/models/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/db/models/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/models/base.py:
--------------------------------------------------------------------------------
1 | """Declaring base model classes for sqlalchemy models."""
2 | from sqlalchemy import Column, DateTime, Integer
3 | from sqlalchemy.ext.declarative import declared_attr
4 | from sqlalchemy.orm import declarative_base, declarative_mixin
5 | from sqlalchemy.sql import func
6 |
7 |
8 | Base = declarative_base()
9 |
10 | @declarative_mixin
11 | class BaseDBModel:
12 | """Class defining common db model components."""
13 | # autoinc pk key
14 | id = Column(Integer, primary_key=True, autoincrement=True)
15 | updated_at = Column(DateTime, server_default=func.now())
16 | __name__: str
17 |
18 | # if not declared generate tablename automatically based on class name
19 | @declared_attr
20 | def __tablename__(cls) -> str:
21 | return cls.__name__.lower()
22 |
23 | # refresh server defaults with asyncio
24 | # https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#synopsis-orm
25 | # required in order to access columns with server defaults
26 | # or SQL expression defaults, subsequent to a flush, without
27 | # triggering an expired load
28 | __mapper_args__ = {"eager_defaults": True}
29 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/models/children.py:
--------------------------------------------------------------------------------
1 | """Sqlalchemy model for 'parent' table."""
2 | from sqlalchemy import Column, Date, ForeignKey, Integer, Numeric, String
3 | from sqlalchemy.orm import relationship
4 |
5 | from app.db.models.base import Base, BaseDBModel
6 | from app.db.models.metadata import metadata_family
7 |
8 |
9 | class Child(Base, BaseDBModel):
10 | """ Database model representing 'parent' table in db.
11 |
12 | 'id' and 'tablename' are created automatically by 'BaseDBModel'.
13 | """
14 | __metadata__ = metadata_family
15 |
16 | name = Column(String)
17 | birthdate = Column(Date)
18 | height = Column(Numeric)
19 | hobby= Column(String)
20 | parent_id = Column(Integer, ForeignKey("parent.id"))
21 | parent = relationship("Parent", back_populates="children")
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/models/metadata.py:
--------------------------------------------------------------------------------
1 | """Definition of metadata objects."""
2 | from sqlalchemy import MetaData
3 |
4 | metadata_family = MetaData()
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/models/parents.py:
--------------------------------------------------------------------------------
1 | """Sqlalchemy model for 'parent' table.
2 |
3 | This is the basic sqlalchemy relationship example
4 | representing a simple 'ONe-To-Many' relationship pattern.
5 | """
6 | from sqlalchemy import Column, Date, Numeric, String
7 | from sqlalchemy.orm import relationship
8 |
9 | from app.db.models.base import Base, BaseDBModel
10 | from app.db.models.metadata import metadata_family
11 |
12 |
13 | class Parent(Base, BaseDBModel):
14 | """ Database model representing 'parent' table in db.
15 |
16 | 'id' and 'tablename' are created automatically by 'BaseDBModel'.
17 | """
18 | __metadata__ = metadata_family
19 |
20 | name = Column(String)
21 | birthdate = Column(Date)
22 | height = Column(Numeric)
23 |
24 | children = relationship("Child", back_populates="parent")
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/repositories/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/db/repositories/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/repositories/base.py:
--------------------------------------------------------------------------------
1 | """Abstract CRUD Repo definitions."""
2 | from abc import ABC
3 | from typing import List, TypeVar
4 |
5 | from loguru import logger
6 | from sqlalchemy import select
7 | from sqlalchemy.ext.asyncio import AsyncSession
8 |
9 | from app.db.models.base import Base
10 | from app.models.base import BaseSchema
11 |
12 | ## ===== Custom Type Hints ===== ##
13 | # sqlalchemy models
14 | SQLA_MODEL = TypeVar("SQLA_MODEL", bound=Base)
15 |
16 | # pydantic models
17 | CREATE_SCHEMA = TypeVar("CREATE_SCHEMA", bound=BaseSchema)
18 | READ_OPTIONAL_SCHEMA = TypeVar("READ_OPTIONAL_SCHEMA", bound=BaseSchema)
19 |
20 |
21 | ## ===== CRUD Repo ===== ##
22 | class SQLAlchemyRepository(ABC):
23 | """Abstract SQLAlchemy repo defining basic database operations.
24 |
25 | Basic CRUD methods used by domain models to interact with the
26 | database are defined here.
27 | """
28 | def __init__(
29 | self,
30 | db: AsyncSession,
31 | ) -> None:
32 | self.db = db
33 |
34 | # models and schemas object instanziation and validation
35 | sqla_model = SQLA_MODEL
36 |
37 | create_schema = CREATE_SCHEMA
38 | read_optional_schema = READ_OPTIONAL_SCHEMA
39 |
40 | ## ===== Basic Crud Operations ===== ##
41 | async def create(
42 | self,
43 | obj_new: create_schema
44 | ) -> sqla_model | None:
45 | """Commit new object to the database."""
46 | try:
47 | db_obj_new = self.sqla_model(**obj_new.dict())
48 | self.db.add(db_obj_new)
49 |
50 | await self.db.commit()
51 | await self.db.refresh(db_obj_new)
52 |
53 | logger.success(f"Created new entity: {db_obj_new}.")
54 |
55 | return db_obj_new
56 |
57 | except Exception as e:
58 |
59 | await self.db.rollback()
60 |
61 | logger.exception("Error while uploading new object to database")
62 | logger.exception(e)
63 |
64 | return None
65 |
66 |
67 | async def read_by_id(
68 | self,
69 | id: int,
70 | ) -> sqla_model | None:
71 | """Get object by id or return None."""
72 | res = await self.db.get(self.sqla_model, id)
73 |
74 | return res
75 |
76 |
77 | async def read_optional(
78 | self,
79 | query_schema: read_optional_schema,
80 | ) -> List[sqla_model] | None:
81 | """Get list of all objects that match with query_schema.
82 |
83 | If values in query schema are not provided, they will default to None and
84 | will not be searched for. To search for None values specifically provide
85 | desired value set to None.
86 | """
87 | filters: dict = query_schema.dict(exclude_none=True)
88 | stmt = select(self.sqla_model).filter_by(**filters).order_by(self.sqla_model.id)
89 |
90 | res = await self.db.execute(stmt)
91 |
92 | return res.scalars().all()
93 |
94 |
95 | async def delete(
96 | self,
97 | id: int,
98 | ) -> sqla_model | None:
99 | """Delete object from db by id or None if object not found in db"""
100 | res = await self.db.get(self.sqla_model, id)
101 | if res:
102 |
103 | await self.db.delete(res)
104 | await self.db.commit()
105 |
106 | logger.success("Entitiy: {res} successfully deleted from database.")
107 |
108 | else:
109 | logger.error(f"Object with id = {id} not found in query")
110 |
111 | return res
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/repositories/children.py:
--------------------------------------------------------------------------------
1 | """Domain Repository for 'child' entity.
2 |
3 | All logic related to the child entity is defined and grouped here.
4 | """
5 | from app.db.models.children import Child as ChildModel
6 | from app.db.repositories.base import SQLAlchemyRepository
7 | from app.models.domain.children import ChildCreate
8 | from app.models.utility_schemas.children import ChildOptionalSchema
9 |
10 |
11 | class ChildRepository(SQLAlchemyRepository):
12 | """Handle all logic related to Child entity.
13 |
14 | Inheritence from 'SQLAlchemyRepository' allows for
15 | crud functionality, only schemata and models used have to be defined.
16 | """
17 | sqla_model = ChildModel
18 |
19 | create_schema = ChildCreate
20 | read_optional_schema = ChildOptionalSchema
--------------------------------------------------------------------------------
/src/fastapi_server/app/db/repositories/parents.py:
--------------------------------------------------------------------------------
1 | """Domain Repository for 'parent' entity.
2 |
3 | All logic related to the parent entity is defined and grouped here.
4 | """
5 | from typing import List
6 |
7 | from sqlalchemy import select
8 | from sqlalchemy.orm import selectinload
9 |
10 | from app.db.models.parents import Parent as ParentModel
11 | from app.db.models.children import Child as ChildModel
12 | from app.db.repositories.base import SQLAlchemyRepository
13 | from app.models.domain.parents import ParentCreate
14 | from app.models.utility_schemas.parents import ParentOptionalSchema
15 |
16 |
17 | class ParentRepository(SQLAlchemyRepository):
18 | """Handle all logic related to Parent entity.
19 |
20 | Inheritence from 'SQLAlchemyRepository' allows for
21 | crud functionality, only schemata and models used have to be defined.
22 | """
23 | sqla_model = ParentModel
24 |
25 | create_schema = ParentCreate
26 | read_optional_schema = ParentOptionalSchema
27 |
28 |
29 | # Testing relationship patterns are working
30 | async def get_parent_children_by_id(
31 | self,
32 | id,
33 | ) -> List[sqla_model] | None:
34 | """Get all children belonging to a certain parent."""
35 | stmt = select(self.sqla_model).options(selectinload(self.sqla_model.children)).filter_by(id=id)
36 |
37 | res = await self.db.execute(stmt)
38 |
39 | parent = res.scalars().first()
40 | if parent is None:
41 | return None
42 | else:
43 | return parent.children
--------------------------------------------------------------------------------
/src/fastapi_server/app/fastapi_server.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | # -*- coding: utf-8 -*-
3 | """FastAPI server.
4 |
5 | Main file of FastAPI application.
6 |
7 | TODO:
8 | * app lvl:
9 | 1. Improve logging
10 | 2. create tag metadata with pydantic
11 |
12 | * module lvl:
13 | 1. improve validation for dates in pydantic,
14 | only accepts datetimes without the 'Z' suffix.
15 |
16 | @Author: Lukas Reinhardt
17 | @Maintainer: Lukas Reinhardt
18 | """
19 | from fastapi import FastAPI
20 | from loguru import logger
21 |
22 | from app.api.routes.router import router as api_router
23 | from app.core.config import add_middleware, get_app_settings
24 | from app.db.db_session import initialize_database
25 |
26 |
27 | def get_app() -> FastAPI:
28 | """Instanciating and setting up FastAPI application."""
29 | settings = get_app_settings()
30 |
31 | app = FastAPI(**settings.fastapi_kwargs)
32 |
33 | add_middleware(app)
34 |
35 | app.include_router(api_router, prefix=settings.api_prefix)
36 |
37 | @app.on_event("startup")
38 | async def startup_event() -> None:
39 | await initialize_database()
40 |
41 | settings.configure_logging()
42 |
43 | return app
44 |
45 |
46 | app = get_app()
47 |
48 |
49 |
50 | # ===== App Info Endpoints ===== #
51 | @app.get("/")
52 | async def root():
53 |
54 | return {"message": "OK"}
55 |
56 | @app.get("/settings")
57 | async def get_app_info():
58 | settings = get_app_settings()
59 | info = {
60 | "app_env": settings.app_env,
61 | "db_settings": settings.database_settings,
62 | "database url": settings.database_url,
63 | "app info": settings.fastapi_kwargs,
64 | }
65 |
66 | return info
67 |
68 | @app.get("/logger_test")
69 | async def test_logger():
70 | logger.info("This is an info")
71 | logger.warning("This is a warning")
72 | logger.error("This is an error")
73 | logger.critical("Shit's about to blow up")
74 |
75 | return {"message": "See log types produced by app"}
76 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/models/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/models/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/models/base.py:
--------------------------------------------------------------------------------
1 | """Base classes for pydantic domain models.
2 |
3 | Allows for pydantic validation via inheritence from pydantics 'BaseModel'
4 | """
5 | from pydantic import BaseModel
6 |
7 |
8 | class BaseSchema(BaseModel):
9 | """Base pydantic schema for domain models.
10 |
11 | Share common logic here.
12 | """
13 | pass
14 |
15 | class IDSchemaMixin(BaseModel):
16 | """Base pydantic schema to be inherited from by database schemata."""
17 | id: int
18 |
19 | class Config(BaseModel.Config):
20 | # allow database schematas mapping to ORM objects
21 | orm_mode = True
--------------------------------------------------------------------------------
/src/fastapi_server/app/models/domain/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/models/domain/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/models/domain/children.py:
--------------------------------------------------------------------------------
1 | """Pydantic domain models for 'children' ressource."""
2 | import datetime as dt
3 | from typing import Optional
4 |
5 | from app.models.base import BaseSchema, IDSchemaMixin
6 |
7 |
8 | class ChildBase(BaseSchema):
9 | name: str
10 | birthdate: Optional[dt.date]
11 | height: Optional[float]
12 | hobby: Optional[str]
13 | parent_id: int
14 |
15 | class ChildCreate(ChildBase):
16 | pass
17 |
18 | class ChildUpdate(ChildBase):
19 | id: int
20 |
21 | class ChildInDB(ChildBase, IDSchemaMixin):
22 | """Schema for 'child' in database."""
23 | updated_at: dt.datetime
--------------------------------------------------------------------------------
/src/fastapi_server/app/models/domain/parents.py:
--------------------------------------------------------------------------------
1 | """Pydantic domain models for 'parents' ressource."""
2 | import datetime as dt
3 | from typing import Optional
4 |
5 | from app.models.base import BaseSchema, IDSchemaMixin
6 |
7 |
8 | class ParentBase(BaseSchema):
9 | name: str
10 | birthdate: Optional[dt.date]
11 | height: Optional[float]
12 |
13 | class ParentCreate(ParentBase):
14 | pass
15 |
16 | class ParentUpdate(ParentBase):
17 | id: int
18 |
19 | class ParentInDB(ParentBase, IDSchemaMixin):
20 | """Schema for 'parent' in database."""
21 | updated_at: dt.datetime
--------------------------------------------------------------------------------
/src/fastapi_server/app/models/utility_schemas/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/app/models/utility_schemas/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/app/models/utility_schemas/children.py:
--------------------------------------------------------------------------------
1 | """Pydantic schmata used by 'children' ressources."""
2 | import datetime as dt
3 | from typing import Optional
4 |
5 | from app.models.base import BaseSchema
6 |
7 |
8 | class ChildOptionalSchema(BaseSchema):
9 | """Used to query 'children' table against.
10 |
11 | All optional allows to query for every attricbute optionally.
12 | """
13 | id: Optional[int] = None
14 | name: Optional[str] = None
15 | birthdate: Optional[dt.date] = None
16 | height: Optional[float] = None
17 | hobby: Optional[str] = None
18 | updated_at: Optional[dt.datetime] = None
19 |
--------------------------------------------------------------------------------
/src/fastapi_server/app/models/utility_schemas/parents.py:
--------------------------------------------------------------------------------
1 | """Pydantic schmata used by 'parent' ressources."""
2 | import datetime as dt
3 | from typing import Optional
4 |
5 | from app.models.base import BaseSchema
6 |
7 |
8 | class ParentOptionalSchema(BaseSchema):
9 | """Used to query 'parents' table against.
10 |
11 | All optional allows to query for every attricbute optionally.
12 | """
13 | id: Optional[int]
14 | name: Optional[str]
15 | birthdate: Optional[dt.date]
16 | height: Optional[float]
17 | updated_at: Optional[dt.datetime]
--------------------------------------------------------------------------------
/src/fastapi_server/migrations/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration with an async dbapi.
--------------------------------------------------------------------------------
/src/fastapi_server/migrations/env.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from logging.config import fileConfig
3 | import os
4 | import pathlib
5 | import sys
6 |
7 | from alembic import context
8 | from loguru import logger
9 | from sqlalchemy import engine_from_config
10 | from sqlalchemy.ext.asyncio import AsyncEngine
11 | from sqlalchemy.pool import NullPool
12 |
13 | # allow migrations to import from 'app'
14 | sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
15 | from app.core.config import get_app_settings
16 | from app.db.models.base import Base
17 | from app.db.models.metadata import metadata_family
18 |
19 |
20 | settings = get_app_settings()
21 |
22 | # this is the Alembic Config object, which provides
23 | # access to the values within the .ini file in use.
24 | config = context.config
25 |
26 | # Interpret the config file for Python logging.
27 | # This line sets up loggers basically.
28 | # if config.config_file_name is not None:
29 | # fileConfig(config.config_file_name)
30 |
31 | # add your model's MetaData object here
32 | # for 'autogenerate' support
33 | # from myapp import mymodel
34 | # target_metadata = mymodel.Base.metadata
35 | target_metadata = Base.metadata
36 |
37 | # other values from the config, defined by the needs of env.py,
38 | # can be acquired:
39 | # my_important_option = config.get_main_option("my_important_option")
40 | # ... etc.
41 |
42 | def run_migrations_offline():
43 | """Run migrations in 'offline' mode.
44 |
45 | This configures the context with just a URL
46 | and not an Engine, though an Engine is acceptable
47 | here as well. By skipping the Engine creation
48 | we don't even need a DBAPI to be available.
49 |
50 | Calls to context.execute() here emit the given string to the
51 | script output.
52 |
53 | """
54 | url = settings.database_url
55 | context.configure(
56 | url=url,
57 | target_metadata=target_metadata,
58 | literal_binds=True,
59 | dialect_opts={"paramstyle": "named"},
60 | )
61 |
62 | with context.begin_transaction():
63 | context.run_migrations()
64 |
65 |
66 | def do_run_migrations(connection):
67 | context.configure(connection=connection, target_metadata=target_metadata)
68 |
69 | with context.begin_transaction():
70 | context.run_migrations()
71 |
72 |
73 | async def run_migrations_online():
74 | """Run migrations in 'online' mode.
75 |
76 | In this scenario we need to create an Engine
77 | and associate a connection with the context.
78 |
79 | """
80 | # varies between live and test migrations
81 | DATABASE_URL = f"{settings.database_url}_test" if os.environ.get("TESTING") else settings.database_url
82 |
83 | connectable = context.config.attributes.get("connection", None)
84 | config.set_main_option("sqlalchemy.url", DATABASE_URL)
85 | if connectable is None:
86 | connectable = AsyncEngine(
87 | engine_from_config(
88 | context.config.get_section(context.config.config_ini_section),
89 | prefix="sqlalchemy.",
90 | poolclass=NullPool,
91 | # future=True
92 | )
93 | )
94 |
95 | async with connectable.connect() as connection:
96 | await connection.run_sync(do_run_migrations)
97 |
98 | await connectable.dispose()
99 |
100 |
101 | if context.is_offline_mode():
102 | logger.info("Running migrations offline.")
103 | run_migrations_offline()
104 | else:
105 | logger.info("Running migrations online.")
106 | asyncio.run(run_migrations_online())
107 |
--------------------------------------------------------------------------------
/src/fastapi_server/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade():
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade():
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/src/fastapi_server/migrations/versions/98be1d14f054_the_hottest_new_db_changes_around.py:
--------------------------------------------------------------------------------
1 | """The hottest new db changes around
2 |
3 | Revision ID: 98be1d14f054
4 | Revises: d30ab2ee6175
5 | Create Date: 2022-08-08 22:58:37.869955
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | from sqlalchemy.dialects import postgresql
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '98be1d14f054'
14 | down_revision = 'd30ab2ee6175'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.drop_table('parent')
22 | op.drop_table('child')
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade():
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.create_table('child',
29 | sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
30 | sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
31 | sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=True),
32 | sa.Column('birthdate', sa.DATE(), autoincrement=False, nullable=True),
33 | sa.Column('height', sa.NUMERIC(), autoincrement=False, nullable=True),
34 | sa.Column('parent_id', sa.INTEGER(), autoincrement=False, nullable=True),
35 | sa.ForeignKeyConstraint(['parent_id'], ['parent.id'], name='child_parent_id_fkey'),
36 | sa.PrimaryKeyConstraint('id', name='child_pkey')
37 | )
38 | op.create_table('parent',
39 | sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
40 | sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
41 | sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=True),
42 | sa.Column('birthdate', sa.DATE(), autoincrement=False, nullable=True),
43 | sa.Column('height', sa.NUMERIC(), autoincrement=False, nullable=True),
44 | sa.PrimaryKeyConstraint('id', name='parent_pkey')
45 | )
46 | # ### end Alembic commands ###
47 |
--------------------------------------------------------------------------------
/src/fastapi_server/migrations/versions/d30ab2ee6175_the_hottest_new_db_changes_around.py:
--------------------------------------------------------------------------------
1 | """The hottest new db changes around
2 |
3 | Revision ID: d30ab2ee6175
4 | Revises:
5 | Create Date: 2022-08-08 22:35:25.550252
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'd30ab2ee6175'
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | pass
22 | # ### end Alembic commands ###
23 |
24 |
25 | def downgrade():
26 | # ### commands auto generated by Alembic - please adjust! ###
27 | pass
28 | # ### end Alembic commands ###
29 |
--------------------------------------------------------------------------------
/src/fastapi_server/pytest.ini:
--------------------------------------------------------------------------------
1 | # allow pytest to import from "app"
2 | [pytest]
3 | pythonpath = .
4 |
--------------------------------------------------------------------------------
/src/fastapi_server/requirements.txt:
--------------------------------------------------------------------------------
1 | # app
2 | fastapi==0.78.0
3 | pydantic==1.9.1
4 | uvicorn==0.18.2
5 | loguru==0.6.0
6 |
7 | # db
8 | sqlalchemy==1.4.39
9 | alembic==1.8.1
10 | psycopg2-binary==2.9.2 # for ddl in tets migrations
11 | asyncpg==0.26.0
12 | sqlalchemy-utils==0.38.3
13 | greenlet==1.1.2
14 |
15 | # testing
16 | pytest==7.1.1
17 | anyio==3.6.1
18 | httpx==0.23.0
19 | asgi-lifespan==1.0.1
20 |
21 | # settings
22 | python-dotenv==0.20.0
--------------------------------------------------------------------------------
/src/fastapi_server/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/tests/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """Test Setup.
2 |
3 | These fixtures will run before tests are involked with pytest.
4 | """
5 | from glob import glob
6 | import os
7 | from typing import Generator, List, TypeVar
8 | import warnings
9 |
10 | import alembic
11 | from alembic.config import Config
12 | from asgi_lifespan import LifespanManager
13 | from fastapi import FastAPI
14 | from httpx import AsyncClient
15 | from loguru import logger
16 | import pytest
17 | from sqlalchemy import event
18 | from sqlalchemy.exc import SQLAlchemyError
19 | from sqlalchemy.ext.asyncio import AsyncSession
20 | from sqlalchemy.orm import sessionmaker
21 | from sqlalchemy_utils import create_database
22 |
23 | from app.api.dependencies.database import get_async_session
24 | from app.core.config import get_app_settings
25 | from app.db.db_session import get_async_engine
26 | from app.db.models.base import Base
27 | from app.fastapi_server import app
28 | from app.models.base import BaseSchema
29 |
30 |
31 | InDB_SCHEMA = TypeVar("InDB_SCHEMA", bound=BaseSchema)
32 |
33 | settings = get_app_settings()
34 |
35 |
36 | ## ===== Pytest and Backend Setup ===== ##
37 | # =========================================================================== #
38 | # make "trio" warnings go away :)
39 | @pytest.fixture
40 | def anyio_backend():
41 | return 'asyncio'
42 |
43 |
44 | # allow fixtures that are in \tests\fixtures folder to be included in conftest
45 | def refactor(string: str) -> str:
46 |
47 | return string.replace("/", ".").replace("\\", ".").replace(".py", "")
48 |
49 | pytest_plugins = [
50 | refactor(fixture) for fixture in glob("tests/fixtures/*.py") if "__" not in fixture
51 | ]
52 |
53 |
54 |
55 | ## ===== Database Setup ===== ##
56 | # =========================================================================== #
57 | @pytest.fixture
58 | async def seed_db(
59 | Parent1_InDB_Model,
60 | Parent2_InDB_Model,
61 | Child1_InDB_Model,
62 | Child2_InDB_Model,
63 | ) -> List[InDB_SCHEMA]:
64 | """Seed the database with test parents and children."""
65 | database_seed = [
66 | Parent1_InDB_Model,
67 | Parent2_InDB_Model,
68 | Child1_InDB_Model,
69 | Child2_InDB_Model,
70 | ]
71 |
72 | return database_seed
73 |
74 |
75 | @pytest.fixture(scope="session")
76 | async def apply_migrations(seed_db) -> Generator:
77 | """Apply migrations at beginning and end of testing session."""
78 | warnings.filterwarnings("ignore", category=DeprecationWarning)
79 | os.environ["TESTING"] = "1" # tells alembics .env to use test database in migrations
80 | config = Config("alembic.ini")
81 |
82 | async_engine = get_async_engine()
83 |
84 | TEST_DATABASE = f"{async_engine.url}_test"
85 |
86 | async with async_engine.connect() as async_conn:
87 |
88 | # create a test db
89 | await async_conn.run_sync(create_database(TEST_DATABASE))
90 | # use sqlalchemy ddl for initial table setup
91 | await async_conn.run_sync(Base.metadata.create_all)
92 | # automatically revert 'head' to most recennt remaining migration
93 | await alembic.command.stamp(config, "head")
94 | # new revision, get freshest changes in db models
95 | await alembic.command.revision(
96 | config,
97 | message="Revision before test",
98 | autogenerate=True,
99 | )
100 | # apply migrations
101 | await alembic.command.upgrade(config, "head")
102 | # seed the database
103 | async_session = sessionmaker(
104 | async_engine,
105 | expire_on_commit=False,
106 | class_=AsyncSession,
107 | )
108 | async with async_session() as session:
109 | async with session.begin():
110 | session.add_all(seed_db)
111 |
112 | yield
113 |
114 | await alembic.command.downgrade(config, "base")
115 | await async_conn.run_sync(Base.metadata.drop_all)
116 |
117 |
118 |
119 | ## ===== SQLAlchemy Setup ===== ##
120 | # =========================================================================== #
121 | @pytest.fixture
122 | async def session():
123 | """SqlAlchemy testing suite.
124 |
125 | Using ORM while rolling back changes after commit to have independant test cases.
126 |
127 | Implementation of "Joining a Session into an External Transaction (such as for test suite)"
128 | recipe from sqlalchemy docs :
129 | https://docs.sqlalchemy.org/en/14/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites
130 |
131 | Inspiration also found on:
132 | https://github.com/sqlalchemy/sqlalchemy/issues/5811#issuecomment-756269881
133 | """
134 | async_test_engine = get_async_engine()
135 | async with async_test_engine.connect() as conn:
136 |
137 | await conn.begin()
138 | await conn.begin_nested()
139 |
140 | async_session = AsyncSession(
141 | conn,
142 | expire_on_commit=False
143 | )
144 |
145 | @event.listens_for(async_session.sync_session, "after_transaction_end")
146 | def end_savepoint(session, transaction):
147 | if conn.closed:
148 | return
149 | if not conn.in_nested_transaction():
150 | conn.sync_connection.begin_nested()
151 |
152 | yield async_session
153 |
154 | await async_session.close()
155 | await conn.rollback()
156 |
157 |
158 |
159 | ## ===== FastAPI Setup ===== ##
160 | # =========================================================================== #
161 | @pytest.fixture()
162 | async def test_app(session) -> FastAPI:
163 | """Injecting test database as dependancy in app for tests."""
164 |
165 | async def test_get_database() -> Generator:
166 | yield session
167 |
168 | app.dependency_overrides[get_async_session] = test_get_database
169 |
170 | return app
171 |
172 |
173 | @pytest.fixture
174 | async def async_test_client(test_app: FastAPI) -> FastAPI:
175 | """Test client that will be used to make requests against our endpoints."""
176 | async with LifespanManager(test_app):
177 | async with AsyncClient(
178 | app=test_app,
179 | base_url="http://testserver"
180 | ) as ac:
181 | try:
182 |
183 | yield ac
184 |
185 | except SQLAlchemyError as e:
186 | logger.error("Error while yielding test client")
--------------------------------------------------------------------------------
/src/fastapi_server/tests/fixtures/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/tests/fixtures/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/tests/fixtures/children.py:
--------------------------------------------------------------------------------
1 | """Fixtures for testing 'child' ressource."""
2 | import datetime as dt
3 |
4 | from fastapi.encoders import jsonable_encoder
5 | import pytest
6 |
7 | from app.db.models.children import Child as ChildModel
8 | from app.models.domain.children import ChildCreate, ChildInDB
9 |
10 |
11 | ## ===== Valid Child 1 ===== ##
12 | @pytest.fixture
13 | def Child1_Create_Schema() -> ChildCreate:
14 | """Returns a json compatible dict of example ChildCreate model"""
15 | child = ChildCreate(
16 | name="Child 1",
17 | birthdate=dt.datetime(2000, 1, 1),
18 | height=1.80,
19 | hobby="Computer Science",
20 | parent_id=1
21 | )
22 | return jsonable_encoder(child)
23 |
24 | @pytest.fixture
25 | def Child1_InDB_Schema() -> ChildInDB:
26 | """Returns a json compatible dict of example ChildInDB model"""
27 | UPDATED_AT_DEFAULT = dt.datetime.now()
28 | child = ChildInDB(
29 | id=1,
30 | name="Child 1",
31 | birthdate=dt.datetime(2000, 1, 1),
32 | height=1.80,
33 | hobby="Computer Science",
34 | parent_id=1,
35 | updated_at=UPDATED_AT_DEFAULT
36 | )
37 | return jsonable_encoder(child)
38 |
39 | @pytest.fixture
40 | def Child1_InDB_Model(Child1_InDB_Schema) -> ChildModel:
41 | return ChildModel(**Child1_InDB_Schema.dict())
42 |
43 |
44 | ## ===== Valid Child 2 ===== ##
45 | @pytest.fixture
46 | def Child2_Create() -> ChildCreate:
47 | """Returns a json compatible dict of example ChildCreate model"""
48 | child = ChildCreate(
49 | name="Child 2",
50 | birthdate=dt.datetime(2005, 1, 1),
51 | height=1.70,
52 | hobby="Bouldering",
53 | parent_id=1
54 | )
55 | return jsonable_encoder(child)
56 |
57 | @pytest.fixture
58 | def Child2_InDB_Schema() -> ChildInDB:
59 | """Returns a json compatible dict of example ChildInDB model"""
60 | UPDATED_AT_DEFAULT = dt.datetime.now()
61 | child = ChildInDB(
62 | id=2,
63 | name="Child 2",
64 | birthdate=dt.datetime(2005, 1, 1),
65 | height=1.70,
66 | hobby="Bouldering",
67 | parent_id=1,
68 | updated_at=UPDATED_AT_DEFAULT
69 | )
70 | return jsonable_encoder(child)
71 |
72 | @pytest.fixture
73 | def Child2_InDB_Model(Child2_InDB_Schema) -> ChildModel:
74 | return ChildModel(**Child2_InDB_Schema.dict())
--------------------------------------------------------------------------------
/src/fastapi_server/tests/fixtures/parents.py:
--------------------------------------------------------------------------------
1 | """Fixtures for testing 'parent' ressource."""
2 | import datetime as dt
3 | from typing import List
4 |
5 | from fastapi.encoders import jsonable_encoder
6 | import pytest
7 |
8 | from app.db.models.parents import Parent as ParentModel
9 | from app.models.domain.parents import ParentCreate, ParentInDB
10 |
11 |
12 | ## ===== Valid Parent 1 ===== ##
13 | @pytest.fixture
14 | def Parent1_Create() -> ParentCreate:
15 | """Returns a json compatible dict of example ParentCreate model"""
16 | parent = ParentCreate(
17 | name="Parent 1",
18 | birthdate=dt.datetime(1980, 1, 1),
19 | height=1.90,
20 | )
21 | return jsonable_encoder(parent)
22 |
23 | @pytest.fixture
24 | def Parent1_InDB_Schema() -> ParentCreate:
25 | """Returns a json compatible dict of example ParentInDB model"""
26 | UPDATED_AT_DEFAULT = dt.datetime.now()
27 | parent = ParentInDB(
28 | id=1,
29 | name="Parent 1",
30 | birthdate=dt.datetime(1980, 1, 1),
31 | height=1.90,
32 | updated_at=UPDATED_AT_DEFAULT
33 | )
34 | return jsonable_encoder(parent)
35 |
36 | @pytest.fixture
37 | def Parent1_InDB_Model(Parent1_InDB_Schema) -> ParentModel:
38 | return ParentModel(**Parent1_InDB_Schema.dict())
39 |
40 |
41 | ## ====== Valid Parent 2 ===== ##
42 | @pytest.fixture
43 | def Parent2_Create() -> ParentCreate:
44 | """Returns a json compatible dict of example ParentCreate model"""
45 | parent = ParentCreate(
46 | name="Parent 2",
47 | birthdate=dt.datetime(1985, 1, 1),
48 | height=1.90,
49 | )
50 | return jsonable_encoder(parent)
51 |
52 | @pytest.fixture
53 | def Parent2_InDB_Schema() -> ParentCreate:
54 | """Returns a json compatible dict of example ParentInDB model"""
55 | UPDATED_AT_DEFAULT = dt.datetime.now()
56 | parent = ParentInDB(
57 | id=2,
58 | name="Parent 2",
59 | birthdate=dt.datetime(1985, 1, 1),
60 | height=1.90,
61 | updated_at=UPDATED_AT_DEFAULT
62 | )
63 | return jsonable_encoder(parent)
64 |
65 | @pytest.fixture
66 | def Parent2_InDB_Model(Parent2_InDB_Schema) -> ParentModel:
67 | return ParentModel(**Parent2_InDB_Schema.dict())
68 |
69 |
70 | ## ====== Valid Parent 3 ===== ##
71 | @pytest.fixture
72 | def Parent3_Create() -> ParentCreate:
73 | """Returns a json compatible dict of example ParentCreate model"""
74 | parent = ParentCreate(
75 | name="New Parent 3",
76 | birthdate=dt.datetime(1970, 1, 1),
77 | height=1.75,
78 | )
79 | return jsonable_encoder(parent)
80 |
81 | @pytest.fixture
82 | def Parent3_InDB_Schema() -> ParentCreate:
83 | """Returns a json compatible dict of example ParentInDB model"""
84 | UPDATED_AT_DEFAULT = dt.datetime.now()
85 | parent = ParentInDB(
86 | id=3,
87 | name="New Parent 3",
88 | birthdate=dt.datetime(1970, 1, 1),
89 | height=1.75,
90 | updated_at=UPDATED_AT_DEFAULT
91 | )
92 | return jsonable_encoder(parent)
93 |
94 | @pytest.fixture
95 | def Parent3_InDB_Model(Parent3_InDB_Schema) -> ParentModel:
96 | return ParentModel(**Parent3_InDB_Schema.dict())
--------------------------------------------------------------------------------
/src/fastapi_server/tests/unit_tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/tests/unit_tests/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/tests/unit_tests/test_api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/tests/unit_tests/test_api/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/tests/unit_tests/test_api/test_routes/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reinhud/async-fastapi-postgres-template/c5d64f86efaa1e1da11f7fae10990158d692215f/src/fastapi_server/tests/unit_tests/test_api/test_routes/__init__.py
--------------------------------------------------------------------------------
/src/fastapi_server/tests/unit_tests/test_api/test_routes/test_main.py:
--------------------------------------------------------------------------------
1 | """Testing endpoints defined in main."""
2 | from fastapi import status
3 | import pytest
4 |
5 | from app.core.config import get_app_settings
6 |
7 |
8 | @pytest.mark.anyio
9 | class Test_Main:
10 | """Test class for app info endpoints defined in main."""
11 | async def test_root(self, async_test_client):
12 | """Test if app is available."""
13 | res = await async_test_client.get("/")
14 | assert res.status_code == status.HTTP_200_OK
15 | assert res.json() == {"message": "OK"}
16 |
--------------------------------------------------------------------------------
/src/fastapi_server/tests/unit_tests/test_api/test_routes/test_parents.py:
--------------------------------------------------------------------------------
1 | """Testing parent endpoints.
2 |
3 | More like unit tests, we're mocking the actual database calls here.
4 | """
5 | from fastapi import status
6 | from fastapi.encoders import jsonable_encoder
7 | import pytest
8 |
9 | from app.db.repositories.base import SQLAlchemyRepository
10 | from app.db.repositories.parents import ParentRepository
11 | from app.models.utility_schemas.parents import ParentOptionalSchema
12 |
13 |
14 | @pytest.mark.anyio
15 | class Test_Parents_Positive():
16 | """Test class for parent endpoints."""
17 | # Basic Parent Endpoints
18 | # ====================================================================== #
19 | async def test_create_parent_OK(
20 | self,
21 | async_test_client,
22 | Parent3_Create,
23 | Parent3_InDB_Schema,
24 | monkeypatch,
25 | ):
26 | # mock the create method that would invoke actual db calls
27 | async def mock_post(self, obj_new):
28 | return Parent3_InDB_Schema
29 | monkeypatch.setattr(SQLAlchemyRepository, "create", mock_post)
30 |
31 | res = await async_test_client.post(
32 | "/api/parents/post",
33 | json=Parent3_Create
34 | )
35 |
36 | assert res.status_code == status.HTTP_201_CREATED
37 | assert res.json() == Parent3_InDB_Schema
38 |
39 |
40 | async def test_read_parent_by_id_OK(
41 | self,
42 | async_test_client,
43 | Parent3_InDB_Schema,
44 | monkeypatch,
45 | ):
46 | params = {"id": 3}
47 |
48 | async def mock_read_by_id(self, id):
49 | return Parent3_InDB_Schema
50 | monkeypatch.setattr(SQLAlchemyRepository, "read_by_id", mock_read_by_id)
51 |
52 | res = await async_test_client.get(
53 | "/api/parents/get_by_id",
54 | params=params
55 | )
56 |
57 | assert res.status_code == status.HTTP_200_OK
58 | assert res.json() == Parent3_InDB_Schema
59 |
60 |
61 | async def test_read_multiple_parents_OK(
62 | self,
63 | async_test_client,
64 | Parent1_InDB_Schema,
65 | Parent2_InDB_Schema,
66 | monkeypatch,
67 | ):
68 | query_schema = jsonable_encoder(
69 | ParentOptionalSchema(
70 | height=1.90
71 | )
72 | )
73 | parents_in_db = [Parent1_InDB_Schema, Parent2_InDB_Schema]
74 |
75 | async def mock_read_optional(self, query_schema):
76 | return parents_in_db
77 | monkeypatch.setattr(SQLAlchemyRepository, "read_optional", mock_read_optional)
78 |
79 | res = await async_test_client.post(
80 | "/api/parents/get_optional",
81 | json=query_schema
82 | )
83 |
84 | assert res.status_code == status.HTTP_200_OK
85 | assert res.json() == parents_in_db
86 |
87 |
88 | async def test_delete_parent_OK(
89 | self,
90 | async_test_client,
91 | Parent1_InDB_Schema,
92 | monkeypatch,
93 | ):
94 | params = {"id": 1}
95 |
96 | async def mock_delete(self, id):
97 | return Parent1_InDB_Schema
98 | monkeypatch.setattr(SQLAlchemyRepository, "delete", mock_delete)
99 |
100 | res = await async_test_client.delete(
101 | "/api/parents/delete",
102 | params=params
103 | )
104 |
105 | assert res.status_code == status.HTTP_200_OK
106 | assert res.json() == Parent1_InDB_Schema
107 |
108 |
109 | # Basic relationship pattern endpoint
110 | # ====================================================================== #
111 | async def test_get_parent_children_by_id_OK(
112 | self,
113 | async_test_client,
114 | Child1_InDB_Schema,
115 | Child2_InDB_Schema,
116 | monkeypatch,
117 | ):
118 | children_of_id_1 = [Child1_InDB_Schema, Child2_InDB_Schema]
119 | params = {"id": 1}
120 |
121 | async def get_parent_children_by_id(self, id):
122 | return children_of_id_1
123 | monkeypatch.setattr(ParentRepository, "get_parent_children_by_id", get_parent_children_by_id)
124 |
125 | res = await async_test_client.get(
126 | "/api/parents/get_children",
127 | params=params
128 | )
129 |
130 | assert res.status_code == status.HTTP_200_OK
131 | assert res.json() == children_of_id_1
132 |
133 |
134 |
135 | @pytest.mark.anyio
136 | class Test_Parents_Negative():
137 | """Test class for parent endpoints for negative test cases."""
138 | # Basic Parent Endpoints
139 | # ====================================================================== #
140 | async def test_read_parent_by_id_NOT_FOUND(
141 | self,
142 | async_test_client,
143 | monkeypatch,
144 | ):
145 | params = {"id": 999}
146 |
147 | async def mock_read_by_id(self, id):
148 | return None
149 | monkeypatch.setattr(SQLAlchemyRepository, "read_by_id", mock_read_by_id)
150 |
151 | res = await async_test_client.get(
152 | "/api/parents/get_by_id",
153 | params=params
154 | )
155 |
156 | assert res.status_code == status.HTTP_404_NOT_FOUND
157 | detail = res.json()
158 | assert res.json() == {'detail': f'No parent with id = {params["id"]}.'}
159 |
160 |
161 | # Basic relationship pattern endpoint
162 | # ====================================================================== #
163 | async def test_get_parent_children_by_id_NOT_FOUND(
164 | self,
165 | async_test_client,
166 | monkeypatch,
167 | ):
168 | params = {"id": 999}
169 |
170 | async def mock_get_parent_children_by_id(self, id):
171 | return None
172 | monkeypatch.setattr(ParentRepository, "get_parent_children_by_id", mock_get_parent_children_by_id)
173 |
174 | res = await async_test_client.get(
175 | "/api/parents/get_children",
176 | params=params
177 | )
178 |
179 | assert res.status_code == status.HTTP_404_NOT_FOUND
180 | assert res.json() == {'detail': f'Parent with id: {params["id"]} not found.'}
181 |
182 |
--------------------------------------------------------------------------------