├── .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 | --------------------------------------------------------------------------------