├── .editorconfig ├── .flake8 ├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── README.md ├── app ├── alembic.ini ├── api │ ├── __init__.py │ ├── common │ │ └── __init__.py │ ├── example │ │ ├── __init__.py │ │ ├── schemas.py │ │ ├── services.py │ │ └── views.py │ ├── router.py │ └── system │ │ ├── __init__.py │ │ └── views.py ├── conftest.py ├── core │ ├── .env-example │ ├── app.py │ └── config.py ├── db │ ├── __init__.py │ ├── db.py │ ├── migrations │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ └── 2022-07-17-10-45_b6a9795b9043.py │ └── models │ │ ├── __init__.py │ │ ├── common.py │ │ └── example.py ├── requirements │ ├── base.txt │ └── development.txt ├── scripts │ └── docker-entrypoint.sh ├── server.py └── tests │ ├── __init__.py │ ├── test_example.py │ └── test_health.py ├── docker-compose.yml └── docker └── Dockerfile.local /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | tab_width = 4 5 | end_of_line = lf 6 | max_line_length = 88 7 | ij_visual_guides = 88 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{js,py,html}] 12 | charset = utf-8 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.{yml,yaml}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [Makefile] 22 | indent_style = tab 23 | 24 | [.flake8] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | [*.py] 29 | indent_style = space 30 | indent_size = 4 31 | ij_python_from_import_parentheses_force_if_multiline = true 32 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 6 3 | inline-quotes = double 4 | max-line-length = 88 5 | extend-ignore = E203 6 | docstring_style=sphinx 7 | 8 | ignore = 9 | ; Found `f` string 10 | WPS305, 11 | ; Missing docstring in public module 12 | D100, 13 | ; Missing docstring in magic method 14 | D105, 15 | ; Missing docstring in __init__ 16 | D107, 17 | ; Found `__init__.py` module with logic 18 | WPS412, 19 | ; Found class without a base class 20 | WPS306, 21 | ; Missing docstring in public nested class 22 | D106, 23 | ; First line should be in imperative mood 24 | D401, 25 | ; Found `__init__.py` module with logic 26 | WPS326, 27 | ; Found string constant over-use 28 | WPS226, 29 | ; Found upper-case constant in a class 30 | WPS115, 31 | ; Found nested function 32 | WPS602, 33 | ; Found method without arguments 34 | WPS605, 35 | ; Found overused expression 36 | WPS204, 37 | ; Found too many module members 38 | WPS202, 39 | ; Found too high module cognitive complexity 40 | WPS232, 41 | ; line break before binary operator 42 | W503, 43 | ; Found module with too many imports 44 | WPS201, 45 | ; Inline strong start-string without end-string. 46 | RST210, 47 | ; Found nested class 48 | WPS431, 49 | ; Found wrong module name 50 | WPS100, 51 | ; Found too many methods 52 | WPS214, 53 | ; Found too long ``try`` body 54 | WPS229, 55 | ; Found unpythonic getter or setter 56 | WPS615, 57 | ; Found a line that starts with a dot 58 | WPS348, 59 | ; Found complex default value (for dependency injection) 60 | WPS404, 61 | ; not perform function calls in argument defaults (for dependency injection) 62 | B008, 63 | ; line to long 64 | E501, 65 | 66 | per-file-ignores = 67 | ; all tests 68 | test_*.py,tests.py,tests_*.py,*/tests/*,conftest.py: 69 | ; Use of assert detected 70 | S101, 71 | ; Found outer scope names shadowing 72 | WPS442, 73 | ; Found too many local variables 74 | WPS210, 75 | ; Found magic number 76 | WPS432, 77 | ; Missing parameter(s) in Docstring 78 | DAR101, 79 | 80 | ; all init files 81 | __init__.py: 82 | ; ignore not used imports 83 | F401, 84 | ; ignore import with wildcard 85 | F403, 86 | ; Found wrong metadata variable 87 | WPS410, 88 | 89 | exclude = 90 | ./.git, 91 | ./venv, 92 | migrations, 93 | ./var, 94 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | max-parallel: 4 12 | matrix: 13 | python-version: ["3.10", "3.11"] 14 | 15 | services: 16 | postgres: 17 | image: postgres:14 18 | env: 19 | POSTGRES_USER: postgres 20 | POSTGRES_PASSWORD: postgres 21 | POSTGRES_DB: db_test 22 | ports: 23 | - 5432:5432 24 | options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - name: Setup Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | cache: 'pip' 34 | cache-dependency-path: '**/requirements/development.txt' 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install -r app/requirements/development.txt 40 | 41 | - name: Run tests 42 | env: 43 | DB_HOST: localhost 44 | DB_PORT: 5432 45 | DB_USER: postgres 46 | DB_PASS: postgres 47 | DB_BASE: db_test 48 | run: cd app && pytest . 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | 3 | .idea/ 4 | .vscode/ 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | 144 | .python-version 145 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v2.4.0 6 | hooks: 7 | - id: check-ast 8 | - id: trailing-whitespace 9 | - id: check-toml 10 | - id: end-of-file-fixer 11 | 12 | - repo: https://github.com/asottile/add-trailing-comma 13 | rev: v2.1.0 14 | hooks: 15 | - id: add-trailing-comma 16 | 17 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 18 | rev: v2.1.0 19 | hooks: 20 | - id: pretty-format-yaml 21 | args: 22 | - --autofix 23 | - --preserve-quotes 24 | - --indent=2 25 | 26 | - repo: local 27 | hooks: 28 | - id: black 29 | name: Format with Black 30 | entry: black 31 | language: system 32 | types: [python] 33 | 34 | - id: isort 35 | name: isort 36 | entry: isort 37 | language: system 38 | types: [python] 39 | 40 | - id: flake8 41 | name: Check with Flake8 42 | entry: flake8 43 | language: system 44 | pass_filenames: false 45 | types: [python] 46 | args: [--count, .] 47 | 48 | - id: mypy 49 | name: Validate types with MyPy 50 | entry: mypy 51 | language: system 52 | types: [python] 53 | pass_filenames: false 54 | args: 55 | - "app" 56 | - "--ignore-missing-imports" 57 | 58 | - id: yesqa 59 | name: Remove usless noqa 60 | entry: yesqa 61 | language: system 62 | types: [python] 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Starter Project 2 | 3 | Project includes: 4 | 5 | - `fastapi` 6 | - `sqlmodel` 7 | - `alembic` 8 | 9 | ## 10 | 11 | ## Models 12 | 13 | Check db/models and migrations, there is one example. 14 | 15 | ## Using docker 16 | 17 | Setup env variables in `app/core/.env` using `app/core/.env-example` 18 | 19 | #### Install and run 20 | 21 | ```bash 22 | docker-compose up -d web 23 | 24 | # you can track logs with: 25 | docker-compose logs -f --tail=100 web 26 | ``` 27 | 28 | Go to: http://localhost:8000/api/docs/ 29 | 30 | #### Migrations 31 | 32 | Create migrations 33 | 34 | ```bash 35 | docker-compose exec web alembic revision --autogenerate -m "Example model" 36 | ``` 37 | 38 | Apply migrations 39 | 40 | ```bash 41 | docker-compose exec web alembic upgrade head 42 | ``` 43 | 44 | #### Tests 45 | 46 | Run tests 47 | 48 | ```bash 49 | docker-compose exec web pytest . 50 | ``` 51 | 52 | ## Without docker 53 | 54 | #### Install 55 | 56 | ```bash 57 | cd app/ 58 | pip install -r requirements/development.txt 59 | ``` 60 | 61 | Setup env variables in `app/core/.env`. 62 | 63 | #### Run 64 | 65 | ```bash 66 | cd app/ 67 | python app/server.py 68 | ``` 69 | 70 | Go to: http://localhost:8000/api/docs/ 71 | 72 | #### Migrations 73 | 74 | Create migrations 75 | 76 | ```bash 77 | alembic revision --autogenerate -m "Example model" 78 | ``` 79 | 80 | Apply migrations 81 | 82 | ```bash 83 | alembic upgrade head 84 | ``` 85 | 86 | #### Tests 87 | 88 | Run tests 89 | 90 | ```bash 91 | pytest . 92 | ``` 93 | 94 | ## Environment Variables 95 | 96 | To run this project, you will need to add the following environment variables to your app/core/.env file 97 | 98 | `BASE_URL` - default: http://localhost:8000 99 | 100 | `RELOAD` - default: false 101 | 102 | `DB_HOST` - default: localhost 103 | 104 | `DB_PORT` - default: 5432 105 | 106 | `DB_USER` - default: postgres 107 | 108 | `DB_PASS` - default: postgres 109 | 110 | `DB_BASE` - default: db 111 | 112 | `DB_ECHO` - default: false 113 | -------------------------------------------------------------------------------- /app/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = db/migrations 6 | 7 | # template used to generate migration files 8 | file_template = %%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d-%%(minute).2d_%%(rev)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" 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. Valid values are: 43 | # 44 | # version_path_separator = : 45 | # version_path_separator = ; 46 | # version_path_separator = space 47 | version_path_separator = os # default: use os.pathsep 48 | 49 | # the output encoding used when revision files 50 | # are written from script.py.mako 51 | output_encoding = utf-8 52 | 53 | 54 | [post_write_hooks] 55 | # post_write_hooks defines scripts or Python functions that are run 56 | # on newly generated revision scripts. See the documentation for further 57 | # detail and examples 58 | 59 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 60 | # hooks = black,autoflake,isort 61 | 62 | # black.type = console_scripts 63 | # black.entrypoint = black 64 | 65 | # autoflake.type = console_scripts 66 | # autoflake.entrypoint = autoflake 67 | 68 | # isort.type = console_scripts 69 | # isort.entrypoint = isort 70 | 71 | # Logging configuration 72 | [loggers] 73 | keys = root,sqlalchemy,alembic 74 | 75 | [handlers] 76 | keys = console 77 | 78 | [formatters] 79 | keys = generic 80 | 81 | [logger_root] 82 | level = WARN 83 | handlers = console 84 | qualname = 85 | 86 | [logger_sqlalchemy] 87 | level = WARN 88 | handlers = 89 | qualname = sqlalchemy.engine 90 | 91 | [logger_alembic] 92 | level = INFO 93 | handlers = 94 | qualname = alembic 95 | 96 | [handler_console] 97 | class = StreamHandler 98 | args = (sys.stderr,) 99 | level = NOTSET 100 | formatter = generic 101 | 102 | [formatter_generic] 103 | format = %(levelname)-5.5s [%(name)s] %(message)s 104 | datefmt = %H:%M:%S 105 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirzadelic/fastapi-starter-project/37e1a7b12f8a5c0a3295fcbf1b0c5d7084aef037/app/api/__init__.py -------------------------------------------------------------------------------- /app/api/common/__init__.py: -------------------------------------------------------------------------------- 1 | """Common app that will be used in project.""" 2 | -------------------------------------------------------------------------------- /app/api/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirzadelic/fastapi-starter-project/37e1a7b12f8a5c0a3295fcbf1b0c5d7084aef037/app/api/example/__init__.py -------------------------------------------------------------------------------- /app/api/example/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | from uuid import UUID 4 | 5 | from pydantic import BaseModel 6 | 7 | 8 | class ExampleCreateSchema(BaseModel): 9 | id: Optional[UUID] = None 10 | name: str 11 | active: bool 12 | 13 | class Config: 14 | orm_mode = True 15 | 16 | 17 | class ExampleSchema(ExampleCreateSchema): 18 | created_at: Optional[datetime] 19 | updated_at: Optional[datetime] 20 | -------------------------------------------------------------------------------- /app/api/example/services.py: -------------------------------------------------------------------------------- 1 | from api.example.schemas import ExampleCreateSchema 2 | from db.db import db_session 3 | from db.models.example import Example 4 | from fastapi import Depends 5 | from sqlalchemy import select 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | 9 | class ExampleService: 10 | def __init__(self, session: AsyncSession = Depends(db_session)): 11 | self.session = session 12 | 13 | async def get_all_examples(self) -> list[Example]: 14 | examples = await self.session.execute(select(Example)) 15 | 16 | return examples.scalars().fetchall() 17 | 18 | async def create_example(self, data: ExampleCreateSchema) -> Example: 19 | example = Example(**data.dict()) 20 | self.session.add(example) 21 | await self.session.commit() 22 | await self.session.refresh(example) 23 | 24 | return example 25 | -------------------------------------------------------------------------------- /app/api/example/views.py: -------------------------------------------------------------------------------- 1 | from api.example.schemas import ExampleCreateSchema, ExampleSchema 2 | from api.example.services import ExampleService 3 | from db.db import db_session 4 | from db.models.example import Example 5 | from fastapi import APIRouter, Depends 6 | from sqlmodel.ext.asyncio.session import AsyncSession 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.get("/", response_model=list[ExampleSchema]) 12 | async def get_examples( 13 | session: AsyncSession = Depends(db_session), 14 | ) -> list[Example]: 15 | example_service = ExampleService(session=session) 16 | return await example_service.get_all_examples() 17 | 18 | 19 | @router.post("/", response_model=ExampleSchema) 20 | async def create_example( 21 | data: ExampleCreateSchema, 22 | session: AsyncSession = Depends(db_session), 23 | ) -> Example: 24 | example_service = ExampleService(session=session) 25 | example = await example_service.create_example(data) 26 | return example 27 | -------------------------------------------------------------------------------- /app/api/router.py: -------------------------------------------------------------------------------- 1 | from fastapi.routing import APIRouter 2 | 3 | from api.example.views import router as example_router 4 | from api.system.views import router as system_router 5 | 6 | api_router = APIRouter() 7 | api_router.include_router(system_router, prefix="/system", tags=["system"]) 8 | api_router.include_router(example_router, prefix="/example", tags=["example"]) 9 | -------------------------------------------------------------------------------- /app/api/system/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirzadelic/fastapi-starter-project/37e1a7b12f8a5c0a3295fcbf1b0c5d7084aef037/app/api/system/__init__.py -------------------------------------------------------------------------------- /app/api/system/views.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | router = APIRouter() 4 | 5 | 6 | @router.get("/health/") 7 | async def health() -> None: 8 | """ 9 | Checks the health of a project. 10 | 11 | It returns 200 if the project is healthy. 12 | """ 13 | -------------------------------------------------------------------------------- /app/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import AsyncGenerator, Generator 3 | 4 | import pytest 5 | import pytest_asyncio 6 | from asyncpg.exceptions import InvalidCatalogNameError 7 | from core.app import get_app 8 | from db.db import async_engine 9 | from fastapi import FastAPI 10 | from httpx import AsyncClient 11 | from sqlalchemy.orm import sessionmaker 12 | from sqlalchemy.util import concurrency 13 | from sqlalchemy_utils import create_database, database_exists 14 | from sqlmodel import SQLModel 15 | from sqlmodel.ext.asyncio.session import AsyncSession 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def event_loop(request) -> Generator: # : indirect usage 20 | loop = asyncio.get_event_loop_policy().new_event_loop() 21 | yield loop 22 | loop.close() 23 | 24 | 25 | @pytest_asyncio.fixture 26 | async def client(app: FastAPI) -> AsyncGenerator: 27 | async with AsyncClient(app=app, base_url="http://test") as client: 28 | yield client 29 | 30 | 31 | @pytest_asyncio.fixture 32 | def app() -> FastAPI: 33 | return get_app() 34 | 35 | 36 | def create_db_if_not_exists(db_url): 37 | try: 38 | db_exists = database_exists(db_url) 39 | except InvalidCatalogNameError: 40 | db_exists = False 41 | 42 | if not db_exists: 43 | create_database(db_url) 44 | 45 | 46 | @pytest_asyncio.fixture(scope="function") 47 | async def db_session() -> AsyncGenerator: 48 | session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) 49 | async with session() as s: 50 | await concurrency.greenlet_spawn(create_db_if_not_exists, async_engine.url) 51 | async with async_engine.begin() as conn: 52 | await conn.run_sync(SQLModel.metadata.create_all) 53 | 54 | yield s 55 | 56 | async with async_engine.begin() as conn: 57 | await conn.run_sync(SQLModel.metadata.drop_all) 58 | 59 | await async_engine.dispose() 60 | -------------------------------------------------------------------------------- /app/core/.env-example: -------------------------------------------------------------------------------- 1 | # create new with next to this one, with name .env 2 | 3 | RELOAD=True 4 | 5 | DB_HOST=db 6 | DB_PORT=5432 7 | DB_USER=postgres 8 | DB_PASS=postgres 9 | DB_BASE=db 10 | 11 | BASE_URL=http://localhost:8000 12 | -------------------------------------------------------------------------------- /app/core/app.py: -------------------------------------------------------------------------------- 1 | from api.router import api_router 2 | from fastapi import FastAPI 3 | from fastapi.responses import UJSONResponse 4 | 5 | 6 | def get_app() -> FastAPI: 7 | """ 8 | Get FastAPI application. 9 | 10 | This is the main constructor of an application. 11 | 12 | :return: application. 13 | """ 14 | app = FastAPI( 15 | title="FastAPI Starter Project", 16 | description="FastAPI Starter Project", 17 | version="1.0", 18 | docs_url="/api/docs/", 19 | redoc_url="/api/redoc/", 20 | openapi_url="/api/openapi.json", 21 | default_response_class=UJSONResponse, 22 | ) 23 | 24 | app.include_router(router=api_router, prefix="/api") 25 | 26 | return app 27 | -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from sys import modules 3 | 4 | from pydantic import BaseSettings 5 | 6 | BASE_DIR = Path(__file__).parent.resolve() 7 | 8 | 9 | class Settings(BaseSettings): 10 | """Application settings.""" 11 | 12 | ENV: str = "dev" 13 | HOST: str = "0.0.0.0" 14 | PORT: int = 8000 15 | _BASE_URL: str = f"https://{HOST}:{PORT}" 16 | # quantity of workers for uvicorn 17 | WORKERS_COUNT: int = 1 18 | # Enable uvicorn reloading 19 | RELOAD: bool = False 20 | # Database settings 21 | DB_HOST: str = "localhost" 22 | DB_PORT: int = 5432 23 | DB_USER: str = "postgres" 24 | DB_PASS: str = "postgres" 25 | _DB_BASE: str = "db" 26 | DB_ECHO: bool = False 27 | 28 | @property 29 | def DB_BASE(self): 30 | return self._DB_BASE 31 | 32 | @property 33 | def BASE_URL(self) -> str: 34 | return self._BASE_URL if self._BASE_URL.endswith("/") else f"{self._BASE_URL}/" 35 | 36 | @property 37 | def DB_URL(self) -> str: 38 | """ 39 | Assemble Database URL from settings. 40 | 41 | :return: Database URL. 42 | """ 43 | 44 | return f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_BASE}" 45 | 46 | class Config: 47 | env_file = f"{BASE_DIR}/.env" 48 | env_file_encoding = "utf-8" 49 | fields = { 50 | "_BASE_URL": { 51 | "env": "BASE_URL", 52 | }, 53 | "_DB_BASE": { 54 | "env": "DB_BASE", 55 | }, 56 | } 57 | 58 | 59 | class TestSettings(Settings): 60 | @property 61 | def DB_BASE(self): 62 | return f"{super().DB_BASE}_test" 63 | 64 | 65 | settings = TestSettings() if "pytest" in modules else Settings() 66 | -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirzadelic/fastapi-starter-project/37e1a7b12f8a5c0a3295fcbf1b0c5d7084aef037/app/db/__init__.py -------------------------------------------------------------------------------- /app/db/db.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from core.config import settings 4 | from sqlalchemy.ext.asyncio import create_async_engine 5 | from sqlalchemy.orm import sessionmaker 6 | from sqlmodel.ext.asyncio.session import AsyncSession 7 | 8 | async_engine = create_async_engine(settings.DB_URL, echo=settings.DB_ECHO, future=True) 9 | 10 | 11 | async def db_session() -> AsyncGenerator: 12 | async_session = sessionmaker( 13 | bind=async_engine, 14 | class_=AsyncSession, 15 | expire_on_commit=False, 16 | ) 17 | async with async_session() as session: 18 | yield session 19 | -------------------------------------------------------------------------------- /app/db/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /app/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from core.config import settings 6 | from db.models import load_all_models 7 | from sqlalchemy.ext.asyncio.engine import create_async_engine 8 | from sqlmodel import SQLModel 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) # type: ignore 17 | 18 | load_all_models() 19 | # add your model's MetaData object here 20 | # for 'autogenerate' support 21 | # from myapp import mymodel 22 | # target_metadata = mymodel.Base.metadata 23 | target_metadata = SQLModel.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | context.configure( 44 | url=str(settings.DB_URL), 45 | target_metadata=target_metadata, 46 | literal_binds=True, 47 | dialect_opts={"paramstyle": "named"}, 48 | ) 49 | 50 | with context.begin_transaction(): 51 | context.run_migrations() 52 | 53 | 54 | def do_run_migrations(connection): 55 | context.configure(connection=connection, target_metadata=target_metadata) 56 | 57 | with context.begin_transaction(): 58 | context.run_migrations() 59 | 60 | 61 | async def run_migrations_online(): 62 | """Run migrations in 'online' mode. 63 | 64 | In this scenario we need to create an Engine 65 | and associate a connection with the context. 66 | 67 | """ 68 | connectable = create_async_engine(str(settings.DB_URL)) 69 | 70 | async with connectable.connect() as connection: 71 | await connection.run_sync(do_run_migrations) 72 | 73 | 74 | if context.is_offline_mode(): 75 | run_migrations_offline() 76 | else: 77 | asyncio.run(run_migrations_online()) 78 | -------------------------------------------------------------------------------- /app/db/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 | import sqlalchemy as sa 9 | import sqlmodel 10 | from alembic import op 11 | ${imports if imports else ""} 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = ${repr(up_revision)} 15 | down_revision = ${repr(down_revision)} 16 | branch_labels = ${repr(branch_labels)} 17 | depends_on = ${repr(depends_on)} 18 | 19 | 20 | def upgrade(): 21 | ${upgrades if upgrades else "pass"} 22 | 23 | 24 | def downgrade(): 25 | ${downgrades if downgrades else "pass"} 26 | -------------------------------------------------------------------------------- /app/db/migrations/versions/2022-07-17-10-45_b6a9795b9043.py: -------------------------------------------------------------------------------- 1 | """Init migrations 2 | 3 | Revision ID: b6a9795b9043 4 | Revises: 5 | Create Date: 2022-07-17 10:45:33.821637 6 | 7 | """ 8 | import sqlalchemy as sa 9 | import sqlmodel 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "b6a9795b9043" 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 | op.create_table( 22 | "example", 23 | sa.Column( 24 | "id", 25 | sqlmodel.sql.sqltypes.GUID(), 26 | server_default=sa.text("gen_random_uuid()"), 27 | nullable=False, 28 | ), 29 | sa.Column( 30 | "created_at", 31 | sa.DateTime(), 32 | server_default=sa.text("current_timestamp(0)"), 33 | nullable=False, 34 | ), 35 | sa.Column( 36 | "updated_at", 37 | sa.DateTime(), 38 | server_default=sa.text("current_timestamp(0)"), 39 | nullable=False, 40 | ), 41 | sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 42 | sa.Column("active", sa.Boolean(), nullable=True), 43 | sa.PrimaryKeyConstraint("id"), 44 | ) 45 | op.create_index(op.f("ix_example_id"), "example", ["id"], unique=True) 46 | # ### end Alembic commands ### 47 | 48 | 49 | def downgrade(): 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.drop_index(op.f("ix_example_id"), table_name="example") 52 | op.drop_table("example") 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /app/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | from pathlib import Path 3 | 4 | 5 | def load_all_models() -> None: 6 | """Load all models from this folder.""" 7 | package_dir = Path(__file__).resolve().parent 8 | modules = pkgutil.walk_packages( 9 | path=[str(package_dir)], 10 | prefix="db.models.", 11 | ) 12 | for module in modules: 13 | __import__(module.name) # noqa: WPS421 14 | -------------------------------------------------------------------------------- /app/db/models/common.py: -------------------------------------------------------------------------------- 1 | import uuid as uuid_pkg 2 | from datetime import datetime 3 | 4 | from sqlalchemy import text 5 | from sqlmodel import Field, SQLModel 6 | 7 | 8 | class UUIDModel(SQLModel): 9 | id: uuid_pkg.UUID = Field( 10 | default_factory=uuid_pkg.uuid4, 11 | primary_key=True, 12 | index=True, 13 | nullable=False, 14 | sa_column_kwargs={"server_default": text("gen_random_uuid()"), "unique": True}, 15 | ) 16 | 17 | 18 | class TimestampModel(SQLModel): 19 | created_at: datetime = Field( 20 | default_factory=datetime.utcnow, 21 | nullable=False, 22 | sa_column_kwargs={"server_default": text("current_timestamp(0)")}, 23 | ) 24 | 25 | updated_at: datetime = Field( 26 | default_factory=datetime.utcnow, 27 | nullable=False, 28 | sa_column_kwargs={ 29 | "server_default": text("current_timestamp(0)"), 30 | "onupdate": text("current_timestamp(0)"), 31 | }, 32 | ) 33 | -------------------------------------------------------------------------------- /app/db/models/example.py: -------------------------------------------------------------------------------- 1 | from db.models.common import TimestampModel, UUIDModel 2 | 3 | 4 | class Example(TimestampModel, UUIDModel, table=True): 5 | __tablename__ = "example" 6 | 7 | name: str 8 | active: bool = True 9 | 10 | def __repr__(self): 11 | return f"" 12 | -------------------------------------------------------------------------------- /app/requirements/base.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.88.0 2 | uvicorn[standard]==0.20.0 3 | ujson==5.5.0 4 | python-dotenv==0.21.0 5 | sqlmodel==0.0.8 6 | asyncpg==0.27.0 7 | alembic==1.8.1 8 | sqlalchemy-utils==0.38.3 9 | -------------------------------------------------------------------------------- /app/requirements/development.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | mypy==0.961 4 | yesqa==1.3.0 5 | black==22.6.0 6 | isort==5.10.1 7 | pytest==6.2.5 8 | pytest-asyncio==0.19.0 9 | requests==2.28.1 10 | httpx==0.23.0 11 | -------------------------------------------------------------------------------- /app/scripts/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | alembic upgrade head 3 | python server.py 4 | -------------------------------------------------------------------------------- /app/server.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from core.config import settings 3 | 4 | 5 | def main() -> None: 6 | """Entrypoint of the application.""" 7 | uvicorn.run( 8 | "core.app:get_app", 9 | workers=settings.WORKERS_COUNT, 10 | host=settings.HOST, 11 | port=settings.PORT, 12 | reload=settings.RELOAD, 13 | factory=True, 14 | ) 15 | 16 | 17 | if __name__ == "__main__": 18 | main() 19 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirzadelic/fastapi-starter-project/37e1a7b12f8a5c0a3295fcbf1b0c5d7084aef037/app/tests/__init__.py -------------------------------------------------------------------------------- /app/tests/test_example.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from db.models.example import Example 3 | from fastapi import FastAPI, status 4 | from httpx import AsyncClient 5 | from sqlalchemy import func, select 6 | from sqlmodel.ext.asyncio.session import AsyncSession 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_list_example_empty( 11 | client: AsyncClient, 12 | app: FastAPI, 13 | db_session: AsyncSession, 14 | ) -> None: 15 | """ 16 | Checks empty list of example 17 | """ 18 | url = app.url_path_for("get_examples") 19 | response = await client.get(url) 20 | assert response.status_code == status.HTTP_200_OK 21 | assert response.json() == [] 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_list_example( 26 | client: AsyncClient, 27 | app: FastAPI, 28 | db_session: AsyncSession, 29 | ) -> None: 30 | """ 31 | Checks list of example 32 | """ 33 | example_data = {"name": "Example 1", "active": True} 34 | example = Example(**example_data) 35 | db_session.add(example) 36 | await db_session.commit() 37 | 38 | url = app.url_path_for("get_examples") 39 | response = await client.get(url) 40 | 41 | assert response.status_code == status.HTTP_200_OK 42 | data = response.json() 43 | assert len(data) == 1 44 | assert data[0]["name"] == example_data["name"] 45 | assert data[0]["active"] == example_data["active"] 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_create_example( 50 | client: AsyncClient, 51 | app: FastAPI, 52 | db_session: AsyncSession, 53 | ) -> None: 54 | """ 55 | Checks create of example 56 | """ 57 | example_data = {"name": "Example 1", "active": True} 58 | 59 | url = app.url_path_for("get_examples") 60 | response = await client.post(url, json=example_data) 61 | 62 | assert response.status_code == status.HTTP_200_OK 63 | data = response.json() 64 | assert data["name"] == example_data["name"] 65 | assert data["active"] == example_data["active"] 66 | count = await db_session.execute(select([func.count()]).select_from(Example)) 67 | assert count.scalar() == 1 68 | -------------------------------------------------------------------------------- /app/tests/test_health.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI, status 3 | from httpx import AsyncClient 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_health(client: AsyncClient, app: FastAPI) -> None: 8 | """ 9 | Checks the health endpoint. 10 | 11 | :param client: client for the app. 12 | :param app: current FastAPI application. 13 | """ 14 | url = app.url_path_for("health") 15 | response = await client.get(url) 16 | assert response.status_code == status.HTTP_200_OK 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | 5 | web: 6 | build: 7 | context: . 8 | dockerfile: docker/Dockerfile.local 9 | command: ./scripts/docker-entrypoint.sh 10 | volumes: 11 | - ./app:/usr/src/app 12 | ports: 13 | - 8000:8000 14 | depends_on: 15 | db: 16 | condition: service_healthy 17 | links: 18 | - db 19 | 20 | db: 21 | image: postgres:14-alpine 22 | ports: 23 | - 5432:5432 24 | volumes: 25 | - db_data:/var/lib/postgresql/data 26 | environment: 27 | - POSTGRES_USER=postgres 28 | - POSTGRES_PASSWORD=postgres 29 | - POSTGRES_DB=db 30 | healthcheck: 31 | test: ["CMD-SHELL", "pg_isready -U postgres"] 32 | interval: 5s 33 | timeout: 5s 34 | retries: 5 35 | 36 | volumes: 37 | db_data: 38 | -------------------------------------------------------------------------------- /docker/Dockerfile.local: -------------------------------------------------------------------------------- 1 | # pull official base image 2 | FROM python:3.10-slim 3 | 4 | # set working directory 5 | WORKDIR /usr/src/app 6 | 7 | # set environment variables 8 | ENV PYTHONDONTWRITEBYTECODE 1 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | # install system dependencies 12 | RUN apt-get update && apt-get -y install libpq-dev gcc 13 | 14 | # add app 15 | COPY ../app/ . 16 | 17 | # install python dependencies 18 | RUN pip install --upgrade pip 19 | RUN pip install -r requirements/development.txt 20 | 21 | CMD ./scripts/docker-entrypoint.sh 22 | --------------------------------------------------------------------------------