├── .dockerignore ├── .env.example ├── .github └── workflows │ └── run_linters.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Makefile ├── README.md ├── alembic.ini ├── docker-compose.yml ├── docker ├── app │ └── Dockerfile ├── db │ └── Dockerfile ├── init_app.sh └── init_postgres.sh ├── main ├── __init__.py ├── api │ ├── __init__.py │ └── v1 │ │ ├── __init__.py │ │ ├── router.py │ │ └── routes │ │ ├── __init__.py │ │ ├── status.py │ │ ├── tasks.py │ │ └── user.py ├── app.py ├── backend_pre_start.py ├── core │ ├── __init__.py │ ├── config.py │ ├── dependencies.py │ ├── exceptions.py │ ├── logging.py │ ├── security.py │ └── settings │ │ ├── __init__.py │ │ ├── app.py │ │ └── base.py ├── db │ ├── __init__.py │ ├── base.py │ ├── base_class.py │ ├── migrations │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ └── dfb75cfbf652_create_tables.py │ ├── repositories │ │ ├── __init__.py │ │ ├── base.py │ │ ├── tasks.py │ │ └── users.py │ └── session.py ├── models │ ├── __init__.py │ ├── task.py │ └── user.py ├── schemas │ ├── __init__.py │ ├── response.py │ ├── status.py │ ├── tasks.py │ └── user.py ├── services │ ├── __init__.py │ └── user.py └── utils │ ├── __init__.py │ └── tasks.py ├── pyproject.toml ├── requirements-ci.txt ├── requirements.txt ├── setup.cfg └── version.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .ve 2 | .cache 3 | .idea 4 | .git 5 | .DS_Store 6 | __pycache__ 7 | logs 8 | *.db 9 | *.pyc 10 | *.pyo 11 | db.sqlite3 12 | .coverage 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY=changeme 2 | POSTGRES_PORT=5432 3 | POSTGRES_DB=tododb 4 | POSTGRES_PASSWORD=postgres 5 | POSTGRES_USER=postgres 6 | DATABASE_URL=postgresql://postgres:postgres@db:5432/tododb 7 | -------------------------------------------------------------------------------- /.github/workflows/run_linters.yml: -------------------------------------------------------------------------------- 1 | name: Run code style check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout project 15 | uses: actions/checkout@master 16 | with: 17 | ref: ${{ github.ref }} 18 | 19 | - name: Initialize python 3.10 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: 3.10.4 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -r requirements-ci.txt 28 | 29 | - name: Style checking 30 | run: make lint 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ve 2 | .idea 3 | .DS_Store 4 | __pycache__ 5 | *.pyc 6 | logs 7 | .env 8 | .env.test 9 | .env.dev 10 | *.db 11 | .vscode 12 | .coverage 13 | postgres-data 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: 2 | - commit 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v3.4.0 7 | hooks: 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - id: check-yaml 11 | - id: mixed-line-ending 12 | - id: check-case-conflict 13 | - id: requirements-txt-fixer 14 | 15 | - repo: https://gitlab.com/pycqa/flake8 16 | rev: 4.0.1 17 | hooks: 18 | - id: flake8 19 | additional_dependencies: 20 | - wemake-python-styleguide 21 | - flake8-builtins 22 | - flake8-bandit 23 | - bandit 24 | - flake8-builtins 25 | - flake8-annotations-complexity 26 | - flake8-class-attributes-order 27 | - flake8-cognitive-complexity 28 | entry: bash -c 'make style' 29 | args: ['--config=setup.cfg'] 30 | 31 | - repo: local 32 | hooks: 33 | - id: mypy 34 | name: mypy 35 | pass_filenames: false 36 | language: python 37 | entry: bash -c 'make types' 38 | 39 | - repo: https://github.com/psf/black 40 | rev: 21.4b0 41 | hooks: 42 | - id: black 43 | language_version: python 44 | 45 | - repo: https://github.com/PyCQA/isort 46 | rev: 5.8.0 47 | hooks: 48 | - id: isort 49 | 50 | - repo: https://github.com/asottile/pyupgrade 51 | rev: v2.13.0 52 | hooks: 53 | - id: pyupgrade 54 | args: [--py38-plus] 55 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export PYTHONPATH := . 2 | 3 | ve: 4 | python3 -m venv .ve; \ 5 | . .ve/bin/activate; \ 6 | pip install -r requirements.txt; \ 7 | 8 | clean: 9 | test -d .ve && rm -rf .ve 10 | 11 | check_db: 12 | python ./main/backend_pre_start.py 13 | 14 | runserver: 15 | uvicorn main.app:app --host 0.0.0.0 --port 5000 --reload 16 | 17 | runserver_docker: 18 | uvicorn main.app:app --host 0.0.0.0 --port 5000 19 | 20 | install_hooks: 21 | pip install -r requirements-ci.txt; \ 22 | pre-commit install; \ 23 | 24 | run_hooks_on_all_files: 25 | pre-commit run --all-files 26 | 27 | style: 28 | flake8 main 29 | 30 | types: 31 | mypy --namespace-packages -p "main" --config-file setup.cfg 32 | 33 | format: 34 | black main --check 35 | 36 | lint: 37 | flake8 main && isort main --diff && black main --check && mypy --namespace-packages -p "main" --config-file setup.cfg 38 | 39 | migration: 40 | alembic revision --autogenerate -m "$(message)" 41 | 42 | migrate: 43 | alembic upgrade head 44 | 45 | docker_build: 46 | docker-compose up -d --build 47 | 48 | docker_up: 49 | docker-compose up -d 50 | 51 | docker_start: 52 | docker-compose start 53 | 54 | docker_down: 55 | docker-compose down 56 | 57 | docker_remove_dangling_images: 58 | docker images --filter "dangling=true" -q --no-trunc | xargs docker rmi 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | FastAPI Example Todo Application 2 | ==================== 3 | 4 | [![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi)](https://github.com/tiangolo/fastapi) 5 | 6 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) 8 | [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) 9 | [![Pre-commit: enabled](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat)](https://github.com/pre-commit/pre-commit) 10 | 11 | FastAPI example ToDo Application with user authentication. 12 | 13 | ### Stack: 14 | - FastAPI 15 | - PostgreSQL 16 | - SQLAlchemy 17 | - Alembic 18 | - Docker 19 | 20 | Developing 21 | ----------- 22 | 23 | Install pre-commit hooks to ensure code quality checks and style checks 24 | 25 | $ make install_hooks 26 | 27 | Then see `Configuration` section 28 | 29 | You can also use these commands during dev process: 30 | 31 | - To run mypy checks 32 | 33 | $ make types 34 | 35 | - To run flake8 checks 36 | 37 | $ make style 38 | 39 | - To run black checks: 40 | 41 | $ make format 42 | 43 | - To run together: 44 | 45 | $ make lint 46 | 47 | 48 | Configuration 49 | -------------- 50 | 51 | Replace `.env.example` with real `.env`, changing placeholders 52 | 53 | ``` 54 | SECRET_KEY=changeme 55 | POSTGRES_PORT=5432 56 | POSTGRES_DB=tododb 57 | POSTGRES_PASSWORD=postgres 58 | POSTGRES_USER=postgres 59 | DATABASE_URL=postgresql://postgres:postgres@localhost:5432/tododb 60 | ``` 61 | 62 | Local install 63 | ------------- 64 | 65 | Setup and activate a python3 virtualenv via your preferred method. e.g. and install production requirements: 66 | 67 | $ make ve 68 | 69 | For remove virtualenv: 70 | 71 | $ make clean 72 | 73 | 74 | Local run 75 | ------------- 76 | Run migration to create tables 77 | 78 | $ make migrate 79 | 80 | Run pre-start script to check database: 81 | 82 | $ make check_db 83 | 84 | Run server with settings: 85 | 86 | $ make runserver 87 | 88 | 89 | Run in Docker 90 | ------------- 91 | 92 | ### !! Note: 93 | 94 | If you want to run app in `Docker`, change host in `DATABASE_URL` in `.env` file to name of docker db service: 95 | 96 | `DATABASE_URL=postgresql://postgres:postgres@db:5432/tododb` 97 | 98 | Run project in Docker: 99 | 100 | $ make docker_build 101 | 102 | Stop project in Docker: 103 | 104 | $ make docker_down 105 | 106 | ## Register user: 107 | 108 | $ curl -X 'POST' \ 109 | 'http://0.0.0.0:5000/api/v1/user' \ 110 | -H 'accept: application/json' \ 111 | -H 'Content-Type: application/json' \ 112 | -d '{ 113 | "username": "test-user", 114 | "email": "test@test.com", 115 | "full_name": "Test Test", 116 | "password": "weakpassword" 117 | }' 118 | 119 | If everything is fine, check this endpoint: 120 | 121 | $ curl -X "GET" http://0.0.0.0:5000/api/v1/status 122 | 123 | Expected result: 124 | 125 | ``` 126 | { 127 | "success": true, 128 | "version": "", 129 | "message": "FastAPI Todo Application" 130 | } 131 | ``` 132 | 133 | 134 | Web routes 135 | ---------- 136 | All routes are available on ``/`` or ``/redoc`` paths with Swagger or ReDoc. 137 | 138 | 139 | Project structure 140 | ----------------- 141 | Files related to application are in the ``main`` directory. 142 | Application parts are: 143 | ```text 144 | main 145 | ├── __init__.py 146 | ├── api 147 | │   ├── __init__.py 148 | │   └── v1 149 | │   ├── __init__.py 150 | │   ├── router.py 151 | │   └── routes 152 | │   ├── __init__.py 153 | │   ├── status.py 154 | │   ├── tasks.py 155 | │   └── user.py 156 | ├── app.py 157 | ├── core 158 | │   ├── __init__.py 159 | │   ├── config.py 160 | │   ├── dependencies.py 161 | │   ├── exceptions.py 162 | │   ├── logging.py 163 | │   ├── security.py 164 | │   └── settings 165 | │   ├── __init__.py 166 | │   ├── app.py 167 | │   └── base.py 168 | ├── db 169 | │   ├── __init__.py 170 | │   ├── base.py 171 | │   ├── base_class.py 172 | │   ├── migrations 173 | │   │   ├── env.py 174 | │   │   ├── script.py.mako 175 | │   │   └── versions 176 | │   │   └── dfb75cfbf652_create_tables.py 177 | │   ├── repositories 178 | │   │   ├── __init__.py 179 | │   │   ├── base.py 180 | │   │   ├── tasks.py 181 | │   │   └── users.py 182 | │   └── session.py 183 | ├── models 184 | │   ├── __init__.py 185 | │   ├── task.py 186 | │   └── user.py 187 | ├── schemas 188 | │   ├── __init__.py 189 | │   ├── response.py 190 | │   ├── status.py 191 | │   ├── tasks.py 192 | │   └── user.py 193 | ├── services 194 | │   ├── __init__.py 195 | │   └── user.py 196 | └── utils 197 | ├── __init__.py 198 | └── tasks.py 199 | ``` 200 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | script_location = ./main/db/migrations 3 | 4 | [loggers] 5 | keys = root,sqlalchemy,alembic 6 | 7 | [handlers] 8 | keys = console 9 | 10 | [formatters] 11 | keys = generic 12 | 13 | [logger_root] 14 | level = WARN 15 | handlers = console 16 | qualname = 17 | 18 | [logger_sqlalchemy] 19 | level = WARN 20 | handlers = 21 | qualname = sqlalchemy.engine 22 | 23 | [logger_alembic] 24 | level = INFO 25 | handlers = 26 | qualname = alembic 27 | 28 | [handler_console] 29 | class = StreamHandler 30 | args = (sys.stderr,) 31 | level = NOTSET 32 | formatter = generic 33 | 34 | [formatter_generic] 35 | format = %(levelname)-5.5s [%(name)s] %(message)s 36 | datefmt = %H:%M:%S 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | container_name: fastapi-todo-app 6 | build: 7 | context: . 8 | dockerfile: ./docker/app/Dockerfile 9 | volumes: 10 | - ./:/app 11 | ports: 12 | - 5000:5000 13 | environment: 14 | - POSTGRES_HOST=db 15 | - POSTGRES_PORT=${POSTGRES_PORT} 16 | depends_on: 17 | - db 18 | 19 | db: 20 | container_name: fastapi-todo-db 21 | build: 22 | context: . 23 | dockerfile: ./docker/db/Dockerfile 24 | restart: always 25 | ports: 26 | - ${POSTGRES_PORT}:${POSTGRES_PORT} 27 | environment: 28 | - POSTGRES_DB=${POSTGRES_DB} 29 | - POSTGRES_USER=${POSTGRES_USER} 30 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 31 | volumes: 32 | - postgres_data:/var/lib/postgresql/data/ 33 | 34 | volumes: 35 | postgres_data: 36 | -------------------------------------------------------------------------------- /docker/app/Dockerfile: -------------------------------------------------------------------------------- 1 | # Pull official base image 2 | FROM python:3.10.4-slim 3 | 4 | # Set environment variables 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | # Set work directory 9 | WORKDIR /app 10 | 11 | # Install packages for virtual envitonment 12 | RUN apt-get update -y \ 13 | && apt -y update && apt -y upgrade \ 14 | && apt-get install -y build-essential \ 15 | && apt-get install -y python3-virtualenv \ 16 | && apt-get install -y netcat \ 17 | && apt-get autoremove -y \ 18 | && apt-get autoclean -y \ 19 | && apt-get clean -y \ 20 | && rm -rf /var/lib/apt/lists/* 21 | 22 | # Create virtual environment 23 | RUN python3.10 -m venv /ve 24 | 25 | # Enable venv 26 | ENV PATH="/ve/bin:$PATH" 27 | 28 | # Copy requirements file 29 | COPY requirements.txt /app/requirements.txt 30 | 31 | # Install dependencies 32 | RUN pip install --upgrade -r /app/requirements.txt 33 | 34 | # Copy project 35 | COPY . /app 36 | 37 | # Add entrypoint file 38 | COPY /docker/init_app.sh / 39 | RUN chmod +x /init_app.sh 40 | 41 | ENTRYPOINT ["/init_app.sh"] 42 | -------------------------------------------------------------------------------- /docker/db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14.2 2 | 3 | COPY docker/init_postgres.sh /docker-entrypoint-initdb.d/ 4 | -------------------------------------------------------------------------------- /docker/init_app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Waiting for Postgres..." 4 | 5 | while ! nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do 6 | sleep 0.1 7 | done 8 | 9 | echo "PostgreSQL started" 10 | 11 | make migrate 12 | make runserver_docker 13 | 14 | exec "$@" 15 | -------------------------------------------------------------------------------- /docker/init_postgres.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DATABASE_NAME=${POSTGRES_DB} 4 | 5 | # create default database 6 | gosu postgres postgres --single < Status: 11 | """ 12 | Health check for API. 13 | """ 14 | return Status(**response) 15 | -------------------------------------------------------------------------------- /main/api/v1/routes/tasks.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter 4 | from fastapi.params import Depends 5 | from starlette.status import HTTP_201_CREATED 6 | 7 | from main.core.dependencies import ( 8 | basic_security, 9 | get_current_active_user, 10 | get_current_task, 11 | get_current_user, 12 | ) 13 | from main.db.repositories.tasks import TasksRepository, get_tasks_repository 14 | from main.models.task import Task 15 | from main.models.user import User 16 | from main.schemas.response import Response 17 | from main.schemas.tasks import TaskInCreate, TaskInDB, TaskInUpdate, TasksInDelete 18 | 19 | router = APIRouter(dependencies=[Depends(basic_security)]) 20 | 21 | 22 | @router.get("", response_model=Response[List[TaskInDB]]) 23 | def get_all_task( 24 | skip: int = 0, 25 | limit: int = 100, 26 | tasks_repo: TasksRepository = Depends(get_tasks_repository), 27 | current_user: User = Depends(get_current_user), 28 | ) -> Response: 29 | """ 30 | Retrieve all tasks. 31 | """ 32 | tasks = tasks_repo.get_all_by_owner( 33 | owner_id=current_user.id, skip=skip, limit=limit 34 | ) 35 | return Response(data=tasks) 36 | 37 | 38 | @router.get("/{task_id}", response_model=Response[TaskInDB]) 39 | def get_task(task: Task = Depends(get_current_task)) -> Response: 40 | """, 41 | Retrieve a task by `task_id`. 42 | """ 43 | return Response(data=task) 44 | 45 | 46 | @router.post("", response_model=Response[TaskInDB], status_code=HTTP_201_CREATED) 47 | def create_task( 48 | task: TaskInCreate, 49 | tasks_repo: TasksRepository = Depends(get_tasks_repository), 50 | current_user: User = Depends(get_current_active_user), 51 | ) -> Response: 52 | """ 53 | Create new task. 54 | """ 55 | task = tasks_repo.create_with_owner(obj_create=task, owner_id=current_user.id) 56 | return Response(data=task, message="The task was created successfully") 57 | 58 | 59 | @router.put("/{task_id}", response_model=Response[TaskInDB]) 60 | def update_task( 61 | task_in_update: TaskInUpdate, 62 | task: Task = Depends(get_current_task), 63 | tasks_repo: TasksRepository = Depends(get_tasks_repository), 64 | ) -> Response: 65 | """ 66 | Update task by `task_id`. 67 | """ 68 | task = tasks_repo.update(obj=task, obj_update=task_in_update) 69 | return Response(data=task, message="The task was updated successfully") 70 | 71 | 72 | @router.delete("/{task_id}", response_model=Response[TaskInDB]) 73 | def delete_task( 74 | task: Task = Depends(get_current_task), 75 | tasks_repo: TasksRepository = Depends(get_tasks_repository), 76 | ) -> Response: 77 | """ 78 | Delete task by `task_id`. 79 | """ 80 | task = tasks_repo.delete(obj_id=task.id) 81 | return Response(data=task, message="The task was deleted successfully") 82 | 83 | 84 | @router.delete("", response_model=Response[TasksInDelete]) 85 | def delete_tasks( 86 | tasks: TasksInDelete, 87 | tasks_repo: TasksRepository = Depends(get_tasks_repository), 88 | current_user: User = Depends(get_current_active_user), 89 | ) -> Response: 90 | """ 91 | Bulk delete tasks. 92 | """ 93 | tasks = tasks_repo.delete_many_by_owner(obj_ids=tasks.ids, owner_id=current_user.id) 94 | return Response( 95 | data=TasksInDelete(ids=tasks), message="The tasks was deleted successfully" 96 | ) 97 | -------------------------------------------------------------------------------- /main/api/v1/routes/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from main.core.dependencies import get_current_user 4 | from main.models.user import User 5 | from main.schemas.response import Response 6 | from main.schemas.user import UserInCreate, UserInDB, UserLogin, UserToken 7 | from main.services.user import UserService 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("", response_model=Response[UserInDB]) 13 | def get_user(user: User = Depends(get_current_user)) -> Response: 14 | """ 15 | Get current user by provided credentials. 16 | """ 17 | return Response(data=user) 18 | 19 | 20 | @router.post("", response_model=Response[UserInDB]) 21 | def register_user( 22 | user: UserInCreate, user_service: UserService = Depends() 23 | ) -> Response: 24 | """ 25 | Process user registration. 26 | """ 27 | user = user_service.register_user(user_create=user) 28 | return Response(data=user, message="The user was register successfully") 29 | 30 | 31 | @router.post("/login", response_model=Response[UserToken]) 32 | def login_user(user: UserLogin, user_service: UserService = Depends()) -> Response: 33 | """ 34 | Process user login. 35 | """ 36 | token = user_service.login_user(user=user) 37 | return Response(data=token, message="The user authenticated successfully") 38 | -------------------------------------------------------------------------------- /main/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.middleware.cors import CORSMiddleware 3 | 4 | from main.api.v1.router import router as api_router 5 | from main.core.config import get_app_settings 6 | from main.core.exceptions import add_exceptions_handlers 7 | 8 | 9 | def create_app() -> FastAPI: 10 | """ 11 | Application factory, used to create application. 12 | """ 13 | settings = get_app_settings() 14 | 15 | application = FastAPI(**settings.fastapi_kwargs) 16 | 17 | application.add_middleware( 18 | CORSMiddleware, 19 | allow_origins=settings.allowed_hosts, 20 | allow_credentials=True, 21 | allow_methods=["*"], 22 | allow_headers=["*"], 23 | ) 24 | 25 | application.include_router(api_router, prefix="/api/v1") 26 | 27 | add_exceptions_handlers(app=application) 28 | 29 | return application 30 | 31 | 32 | app = create_app() 33 | -------------------------------------------------------------------------------- /main/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from main.core.logging import logger 6 | from main.db.session import SessionLocal 7 | 8 | max_tries = 60 * 2 # 2 minutes 9 | wait_seconds = 5 10 | 11 | 12 | @retry( 13 | stop=stop_after_attempt(max_tries), 14 | wait=wait_fixed(wait_seconds), 15 | before=before_log(logger, logging.INFO), 16 | after=after_log(logger, logging.WARN), 17 | ) 18 | def init() -> None: 19 | db = SessionLocal() 20 | try: 21 | # Try to create session to check if DB is awake 22 | db.execute("SELECT 1") 23 | except Exception as e: 24 | logger.error(e) 25 | raise e 26 | 27 | 28 | def main() -> None: 29 | logger.info("Initializing service") 30 | init() 31 | logger.info("Service finished initializing") 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /main/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-todo-example-app/26561d1953a01f74c0b72ebcd992c56d52db61b3/main/core/__init__.py -------------------------------------------------------------------------------- /main/core/config.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from main.core.settings.app import AppSettings 4 | 5 | 6 | @lru_cache 7 | def get_app_settings() -> AppSettings: 8 | """ 9 | Return application config. 10 | """ 11 | return AppSettings() 12 | -------------------------------------------------------------------------------- /main/core/dependencies.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 3 | from starlette.status import ( 4 | HTTP_400_BAD_REQUEST, 5 | HTTP_403_FORBIDDEN, 6 | HTTP_404_NOT_FOUND, 7 | ) 8 | 9 | from main.core.exceptions import ( 10 | InactiveUserAccountException, 11 | TaskNotFoundException, 12 | UserPermissionException, 13 | ) 14 | from main.db.repositories.tasks import TasksRepository, get_tasks_repository 15 | from main.models.task import Task 16 | from main.models.user import User 17 | from main.services.user import UserService 18 | 19 | basic_security = HTTPBasic() 20 | 21 | 22 | def get_current_user( 23 | user_service: UserService = Depends(), 24 | credentials: HTTPBasicCredentials = Depends(basic_security), 25 | ) -> User: 26 | """ 27 | Return current user. 28 | """ 29 | user = user_service.authenticate( 30 | username=credentials.username, password=credentials.password 31 | ) 32 | return user 33 | 34 | 35 | def get_current_task( 36 | task_id: str, 37 | repo: TasksRepository = Depends(get_tasks_repository), 38 | current_user: User = Depends(get_current_user), 39 | ) -> Task: 40 | """ 41 | Check if task with `task_id` exists in database. 42 | """ 43 | task = repo.get(obj_id=task_id) 44 | if not task: 45 | raise TaskNotFoundException( 46 | message=f"Task with id `{task_id}` not found", 47 | status_code=HTTP_404_NOT_FOUND, 48 | ) 49 | if task.owner_id != current_user.id: 50 | raise UserPermissionException( 51 | message="Not enough permissions", status_code=HTTP_403_FORBIDDEN 52 | ) 53 | return task 54 | 55 | 56 | def get_current_active_user( 57 | user_service: UserService = Depends(), 58 | current_user: User = Depends(get_current_user), 59 | ) -> User: 60 | """ 61 | Return current active user. 62 | """ 63 | if not user_service.check_is_active(user=current_user): 64 | raise InactiveUserAccountException( 65 | message="Inactive user", status_code=HTTP_400_BAD_REQUEST 66 | ) 67 | return current_user 68 | -------------------------------------------------------------------------------- /main/core/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from fastapi import FastAPI, HTTPException 4 | from fastapi.exceptions import RequestValidationError 5 | from pydantic import ValidationError 6 | from starlette.requests import Request 7 | from starlette.responses import JSONResponse 8 | from starlette.status import ( 9 | HTTP_422_UNPROCESSABLE_ENTITY, 10 | HTTP_500_INTERNAL_SERVER_ERROR, 11 | ) 12 | 13 | 14 | def form_error_message(errors: List[dict]) -> List[str]: 15 | """ 16 | Make valid pydantic `ValidationError` messages list. 17 | """ 18 | messages = [] 19 | for error in errors: 20 | field, message = error["loc"][-1], error["msg"] 21 | messages.append(f"`{field}` {message}") 22 | return messages 23 | 24 | 25 | class BaseInternalException(Exception): 26 | """ 27 | Base error class for inherit all internal errors. 28 | """ 29 | 30 | def __init__(self, message: str, status_code: int) -> None: 31 | self.message = message 32 | self.status_code = status_code 33 | 34 | 35 | class TaskNotFoundException(BaseInternalException): 36 | """ 37 | Exception raised when `task_id` field from JSON body not found. 38 | """ 39 | 40 | 41 | class UserAlreadyExistException(BaseInternalException): 42 | """ 43 | Exception raised when user try to login with invalid username. 44 | """ 45 | 46 | 47 | class UserNotFoundException(BaseInternalException): 48 | """ 49 | Exception raised when user try to register with already exist username. 50 | """ 51 | 52 | 53 | class InvalidUserCredentialsException(BaseInternalException): 54 | """ 55 | Exception raised when user try to login with invalid credentials. 56 | """ 57 | 58 | 59 | class InactiveUserAccountException(BaseInternalException): 60 | """ 61 | Exception raised when user try to login to inactive account. 62 | """ 63 | 64 | 65 | class UserPermissionException(BaseInternalException): 66 | """ 67 | Exception raised when user try to read task from other owner. 68 | """ 69 | 70 | 71 | def add_internal_exception_handler(app: FastAPI) -> None: 72 | """ 73 | Handle all internal exceptions. 74 | """ 75 | 76 | @app.exception_handler(BaseInternalException) 77 | async def _exception_handler( 78 | _: Request, exc: BaseInternalException 79 | ) -> JSONResponse: 80 | return JSONResponse( 81 | status_code=exc.status_code, 82 | content={ 83 | "success": False, 84 | "status": exc.status_code, 85 | "type": type(exc).__name__, 86 | "message": exc.message, 87 | }, 88 | ) 89 | 90 | 91 | def add_validation_exception_handler(app: FastAPI) -> None: 92 | """ 93 | Handle `pydantic` validation errors exceptions. 94 | """ 95 | 96 | @app.exception_handler(ValidationError) 97 | async def _exception_handler(_: Request, exc: ValidationError) -> JSONResponse: 98 | return JSONResponse( 99 | status_code=HTTP_422_UNPROCESSABLE_ENTITY, 100 | content={ 101 | "success": False, 102 | "status": HTTP_422_UNPROCESSABLE_ENTITY, 103 | "type": "ValidationError", 104 | "message": "Schema validation error", 105 | "errors": form_error_message(errors=exc.errors()), 106 | }, 107 | ) 108 | 109 | 110 | def add_request_exception_handler(app: FastAPI) -> None: 111 | """ 112 | Handle request validation errors exceptions. 113 | """ 114 | 115 | @app.exception_handler(RequestValidationError) 116 | async def _exception_handler( 117 | _: Request, exc: RequestValidationError 118 | ) -> JSONResponse: 119 | return JSONResponse( 120 | status_code=422, 121 | content={ 122 | "success": False, 123 | "status": HTTP_422_UNPROCESSABLE_ENTITY, 124 | "type": "RequestValidationError", 125 | "message": "Schema validation error", 126 | "errors": form_error_message(errors=exc.errors()), 127 | }, 128 | ) 129 | 130 | 131 | def add_http_exception_handler(app: FastAPI) -> None: 132 | """ 133 | Handle http exceptions. 134 | """ 135 | 136 | @app.exception_handler(HTTPException) 137 | async def _exception_handler(_: Request, exc: HTTPException) -> JSONResponse: 138 | return JSONResponse( 139 | status_code=exc.status_code, 140 | content={ 141 | "success": False, 142 | "status": exc.status_code, 143 | "type": "HTTPException", 144 | "message": exc.detail, 145 | }, 146 | ) 147 | 148 | 149 | def add_internal_server_error_handler(app: FastAPI) -> None: 150 | """ 151 | Handle http exceptions. 152 | """ 153 | 154 | @app.exception_handler(Exception) 155 | async def _exception_handler(_: Request, exc: Exception) -> JSONResponse: 156 | return JSONResponse( 157 | status_code=HTTP_500_INTERNAL_SERVER_ERROR, 158 | content={ 159 | "success": False, 160 | "status": HTTP_500_INTERNAL_SERVER_ERROR, 161 | "type": "HTTPException", 162 | "message": "Internal Server Error", 163 | }, 164 | ) 165 | 166 | 167 | def add_exceptions_handlers(app: FastAPI) -> None: 168 | """ 169 | Base exception handlers. 170 | """ 171 | add_internal_exception_handler(app=app) 172 | add_validation_exception_handler(app=app) 173 | add_request_exception_handler(app=app) 174 | add_http_exception_handler(app=app) 175 | add_internal_server_error_handler(app=app) 176 | -------------------------------------------------------------------------------- /main/core/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module configuration custom logger. 3 | """ 4 | import logging 5 | from typing import List, Optional 6 | 7 | DEFAULT_LOGGER_NAME = "fastapi-todo" 8 | 9 | LOG_MESSAGE_FORMAT = "[%(name)s] [%(asctime)s] %(message)s" 10 | 11 | 12 | class ProjectLogger: 13 | """ 14 | Custom project logger. 15 | """ 16 | 17 | def __init__(self, name: str): 18 | self.name = name 19 | self._logger: Optional[logging.Logger] = None 20 | 21 | def __call__(self, *args: tuple, **kwargs: dict) -> logging.Logger: 22 | return self.logger 23 | 24 | @property 25 | def logger(self) -> logging.Logger: 26 | """ 27 | Return initialized logger object. 28 | """ 29 | if not self._logger: 30 | self._logger = self.create_logger() 31 | return self._logger 32 | 33 | def create_logger(self) -> logging.Logger: 34 | """ 35 | Return configured logger. 36 | """ 37 | logging.basicConfig(format=LOG_MESSAGE_FORMAT) 38 | 39 | custom_logger = logging.getLogger(name=self.name) 40 | custom_logger.setLevel(level=logging.INFO) 41 | 42 | return custom_logger 43 | 44 | 45 | def create_logger(name: str = DEFAULT_LOGGER_NAME) -> logging.Logger: 46 | """ 47 | Initialize logger for project. 48 | """ 49 | return ProjectLogger(name=name)() 50 | 51 | 52 | logger = create_logger() 53 | -------------------------------------------------------------------------------- /main/core/security.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from passlib.context import CryptContext 4 | 5 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 6 | 7 | 8 | def get_password_hash(password: str) -> str: 9 | """ 10 | Convert user password to hash string. 11 | """ 12 | return pwd_context.hash(secret=password) 13 | 14 | 15 | def verify_password(plain_password: str, hashed_password: str) -> bool: 16 | """ 17 | Check if the user password from request is valid. 18 | """ 19 | return pwd_context.verify(secret=plain_password, hash=hashed_password) 20 | 21 | 22 | def get_basic_auth_token(username: str, password: str) -> str: 23 | """ 24 | Return base64 auth token. 25 | """ 26 | return base64.b64encode(s=f"{username}:{password}".encode()).decode() 27 | -------------------------------------------------------------------------------- /main/core/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-todo-example-app/26561d1953a01f74c0b72ebcd992c56d52db61b3/main/core/settings/__init__.py -------------------------------------------------------------------------------- /main/core/settings/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict, List 3 | 4 | from main.core.settings.base import BaseAppSettings 5 | from version import response 6 | 7 | 8 | class AppSettings(BaseAppSettings): 9 | """ 10 | Base application settings 11 | """ 12 | 13 | debug: bool = False 14 | docs_url: str = "/" 15 | openapi_prefix: str = "" 16 | openapi_url: str = "/openapi.json" 17 | redoc_url: str = "/redoc" 18 | title: str = response["message"] 19 | version: str = response["version"] 20 | 21 | secret_key: str 22 | 23 | api_prefix: str = "/api/v1" 24 | 25 | allowed_hosts: List[str] = ["*"] 26 | 27 | logging_level: int = logging.INFO 28 | 29 | database_url: str 30 | min_connection_count: int = 5 31 | max_connection_count: int = 10 32 | 33 | class Config: 34 | validate_assignment = True 35 | 36 | @property 37 | def fastapi_kwargs(self) -> Dict[str, Any]: 38 | return { 39 | "debug": self.debug, 40 | "docs_url": self.docs_url, 41 | "openapi_prefix": self.openapi_prefix, 42 | "openapi_url": self.openapi_url, 43 | "redoc_url": self.redoc_url, 44 | "title": self.title, 45 | "version": self.version, 46 | } 47 | -------------------------------------------------------------------------------- /main/core/settings/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | 4 | class BaseAppSettings(BaseSettings): 5 | """ 6 | Base application setting class. 7 | """ 8 | 9 | class Config: 10 | env_file = ".env" 11 | -------------------------------------------------------------------------------- /main/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-todo-example-app/26561d1953a01f74c0b72ebcd992c56d52db61b3/main/db/__init__.py -------------------------------------------------------------------------------- /main/db/base.py: -------------------------------------------------------------------------------- 1 | # Import all the models, so that Base has them before being 2 | # imported by Alembic 3 | from main.db.base_class import Base # noqa 4 | from main.models.task import Task # noqa 5 | from main.models.user import User # noqa 6 | -------------------------------------------------------------------------------- /main/db/base_class.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy.ext.declarative import as_declarative, declared_attr 4 | 5 | 6 | @as_declarative() 7 | class Base: 8 | id: Any 9 | __name__: str 10 | 11 | @declared_attr 12 | def __tablename__(cls) -> str: 13 | """ 14 | Generate __tablename__ automatically. 15 | """ 16 | return cls.__name__.lower() 17 | -------------------------------------------------------------------------------- /main/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | 6 | from main.core.config import get_app_settings 7 | from main.db.base import Base 8 | 9 | settings = get_app_settings() 10 | 11 | config = context.config 12 | config.set_main_option("sqlalchemy.url", settings.database_url) 13 | fileConfig(config.config_file_name) 14 | 15 | target_metadata = Base.metadata 16 | 17 | 18 | def run_migrations_online(): 19 | connectable = engine_from_config( 20 | config.get_section(config.config_ini_section), 21 | prefix="sqlalchemy.", 22 | poolclass=pool.NullPool, 23 | ) 24 | 25 | with connectable.connect() as connection: 26 | context.configure(connection=connection, target_metadata=target_metadata) 27 | 28 | with context.begin_transaction(): 29 | context.run_migrations() 30 | 31 | 32 | run_migrations_online() 33 | -------------------------------------------------------------------------------- /main/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 | 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 | -------------------------------------------------------------------------------- /main/db/migrations/versions/dfb75cfbf652_create_tables.py: -------------------------------------------------------------------------------- 1 | """Create tables 2 | 3 | Revision ID: dfb75cfbf652 4 | Revises: 5 | Create Date: 2022-04-12 15:21:50.014228 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "dfb75cfbf652" 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "user", 22 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 23 | sa.Column("username", sa.String(), nullable=True), 24 | sa.Column("email", sa.String(), nullable=True), 25 | sa.Column("full_name", sa.String(), nullable=True), 26 | sa.Column("hashed_password", sa.String(), nullable=False), 27 | sa.Column("disabled", sa.Boolean(), nullable=True), 28 | sa.PrimaryKeyConstraint("id"), 29 | sa.UniqueConstraint("username"), 30 | ) 31 | op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False) 32 | op.create_table( 33 | "task", 34 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 35 | sa.Column("title", sa.String(), nullable=True), 36 | sa.Column("done", sa.Boolean(), nullable=True), 37 | sa.Column("owner_id", sa.Integer(), nullable=True), 38 | sa.ForeignKeyConstraint(["owner_id"], ["user.id"]), 39 | sa.PrimaryKeyConstraint("id"), 40 | ) 41 | op.create_index(op.f("ix_task_id"), "task", ["id"], unique=False) 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | op.drop_index(op.f("ix_task_id"), table_name="task") 48 | op.drop_table("task") 49 | op.drop_index(op.f("ix_user_id"), table_name="user") 50 | op.drop_table("user") 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /main/db/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-todo-example-app/26561d1953a01f74c0b72ebcd992c56d52db61b3/main/db/repositories/__init__.py -------------------------------------------------------------------------------- /main/db/repositories/base.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, List, Optional, Type, TypeVar 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic import BaseModel 5 | from sqlalchemy.orm import Session 6 | 7 | from main.db.base_class import Base 8 | 9 | ModelType = TypeVar("ModelType", bound=Base) 10 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 11 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 12 | 13 | 14 | class BaseRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 15 | """ 16 | Base repository with basic methods. 17 | """ 18 | 19 | def __init__(self, db: Session, model: Type[ModelType]) -> None: 20 | """ 21 | CRUD object with default methods to Create, Read, Update, Delete (CRUD). 22 | 23 | :param db: A SQLAlchemy Session object. 24 | :param model: A SQLAlchemy model class. 25 | """ 26 | self.db = db 27 | self.model = model 28 | 29 | def get_all(self) -> List[ModelType]: 30 | """ 31 | Return all objects from specific db table. 32 | """ 33 | return self.db.query(self.model).all() 34 | 35 | def get(self, obj_id: str) -> Optional[ModelType]: 36 | """ 37 | Get object by `id` field. 38 | """ 39 | return self.db.query(self.model).filter(self.model.id == obj_id).first() 40 | 41 | def create(self, obj_create: CreateSchemaType) -> ModelType: 42 | """ 43 | Create new object in db table. 44 | """ 45 | obj = self.model(**obj_create.dict()) 46 | self.db.add(obj) 47 | self.db.commit() 48 | self.db.refresh(obj) 49 | return obj 50 | 51 | def update(self, obj: ModelType, obj_update: UpdateSchemaType) -> ModelType: 52 | """ 53 | Update model object by fields from `obj_update` schema. 54 | """ 55 | obj_data = jsonable_encoder(obj) 56 | update_data = obj_update.dict(exclude_unset=True) 57 | for field in obj_data: 58 | if field in update_data: 59 | setattr(obj, field, update_data[field]) 60 | self.db.add(obj) 61 | self.db.commit() 62 | self.db.refresh(obj) 63 | return obj 64 | 65 | def delete(self, obj_id: int) -> Optional[ModelType]: 66 | """ 67 | Delete object. 68 | """ 69 | obj = self.db.query(self.model).get(obj_id) 70 | self.db.delete(obj) 71 | self.db.commit() 72 | return obj 73 | -------------------------------------------------------------------------------- /main/db/repositories/tasks.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import Depends 4 | from sqlalchemy.orm import Session 5 | 6 | from main.core.config import get_app_settings 7 | from main.db.repositories.base import BaseRepository 8 | from main.db.session import get_db 9 | from main.models.task import Task 10 | from main.schemas.tasks import TaskInCreate, TaskInUpdate 11 | 12 | settings = get_app_settings() 13 | 14 | 15 | class TasksRepository(BaseRepository[Task, TaskInCreate, TaskInUpdate]): 16 | """ 17 | Repository to manipulate with the task. 18 | """ 19 | 20 | def get_all_by_owner( 21 | self, *, owner_id: int, skip: int = 0, limit: int = 100 22 | ) -> List[Task]: 23 | """ 24 | Get all tasks created by specific user with id `owner_id`. 25 | """ 26 | return ( 27 | self.db.query(self.model) 28 | .filter(Task.owner_id == owner_id) 29 | .offset(skip) 30 | .limit(limit) 31 | .all() 32 | ) 33 | 34 | def create_with_owner(self, *, obj_create: TaskInCreate, owner_id: int) -> Task: 35 | """ 36 | Create new task by specific user with id `owner_id`. 37 | """ 38 | obj = self.model(**obj_create.dict(), owner_id=owner_id) 39 | self.db.add(obj) 40 | self.db.commit() 41 | self.db.refresh(obj) 42 | return obj 43 | 44 | def delete_many_by_owner(self, obj_ids: List[int], owner_id: int) -> List[int]: 45 | """ 46 | Bulk delete objects. 47 | """ 48 | query = ( 49 | self.db.query(self.model) 50 | .filter(Task.owner_id == owner_id) 51 | .filter(self.model.id.in_(obj_ids)) 52 | ) 53 | query.delete(synchronize_session=False) 54 | self.db.commit() 55 | return obj_ids 56 | 57 | 58 | def get_tasks_repository(session: Session = Depends(get_db)) -> TasksRepository: 59 | return TasksRepository(db=session, model=Task) 60 | -------------------------------------------------------------------------------- /main/db/repositories/users.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import Depends 4 | from sqlalchemy.orm import Session 5 | 6 | from main.core.config import get_app_settings 7 | from main.db.repositories.base import BaseRepository 8 | from main.db.session import get_db 9 | from main.models.user import User 10 | from main.schemas.user import UserInCreate, UserInUpdate 11 | 12 | settings = get_app_settings() 13 | 14 | 15 | class UsersRepository(BaseRepository[User, UserInCreate, UserInUpdate]): 16 | """ 17 | Repository to manipulate with the task. 18 | """ 19 | 20 | def get_by_username(self, username: str) -> Optional[User]: 21 | """ 22 | Get user by `username` field. 23 | """ 24 | return self.db.query(User).filter(User.username == username).first() 25 | 26 | @staticmethod 27 | def is_active(user: User) -> bool: 28 | """ 29 | Check if user is active. 30 | """ 31 | return not user.disabled 32 | 33 | 34 | def get_users_repository(session: Session = Depends(get_db)) -> UsersRepository: 35 | return UsersRepository(db=session, model=User) 36 | -------------------------------------------------------------------------------- /main/db/session.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | from main.core.config import get_app_settings 7 | 8 | settings = get_app_settings() 9 | 10 | engine = create_engine(url=settings.database_url, echo=True, future=True) 11 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 12 | 13 | 14 | def get_db() -> Generator: 15 | """ 16 | Generator dependency yield database connection. 17 | """ 18 | db = SessionLocal() 19 | try: 20 | yield db 21 | finally: 22 | db.close() 23 | -------------------------------------------------------------------------------- /main/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-todo-example-app/26561d1953a01f74c0b72ebcd992c56d52db61b3/main/models/__init__.py -------------------------------------------------------------------------------- /main/models/task.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from sqlalchemy import Boolean, Column, ForeignKey, Integer, String 4 | from sqlalchemy.orm import relationship 5 | 6 | from main.db.base_class import Base 7 | 8 | if TYPE_CHECKING: 9 | from .user import User # noqa 10 | 11 | 12 | class Task(Base): 13 | id = Column(Integer, primary_key=True, index=True, autoincrement=True) 14 | title = Column(String) 15 | done = Column(Boolean, default=False) 16 | 17 | owner_id = Column(Integer, ForeignKey("user.id")) 18 | owner = relationship("User", back_populates="tasks") 19 | 20 | def __init__(self, title: str, owner_id: int) -> None: 21 | self.title = title 22 | self.owner_id = owner_id 23 | -------------------------------------------------------------------------------- /main/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from sqlalchemy import Boolean, Column, Integer, String 4 | from sqlalchemy.orm import relationship 5 | 6 | from main.core.security import get_password_hash 7 | from main.db.base_class import Base 8 | 9 | if TYPE_CHECKING: 10 | from main.models.task import Task # noqa 11 | 12 | 13 | class User(Base): 14 | id = Column(Integer, primary_key=True, index=True, autoincrement=True) 15 | username = Column(String, unique=True) 16 | email = Column(String) 17 | full_name = Column(String) 18 | hashed_password = Column(String, nullable=False) 19 | disabled = Column(Boolean, default=False) 20 | 21 | tasks = relationship("Task", back_populates="owner") 22 | 23 | def __init__( 24 | self, username: str, email: str, full_name: str, password: str 25 | ) -> None: 26 | self.username = username 27 | self.email = email 28 | self.full_name = full_name 29 | self.hashed_password = get_password_hash(password=password) 30 | -------------------------------------------------------------------------------- /main/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-todo-example-app/26561d1953a01f74c0b72ebcd992c56d52db61b3/main/schemas/__init__.py -------------------------------------------------------------------------------- /main/schemas/response.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, Optional, TypeVar 2 | 3 | from pydantic.generics import GenericModel 4 | 5 | ResponseData = TypeVar("ResponseData") 6 | 7 | 8 | class Response(GenericModel, Generic[ResponseData]): 9 | success: bool = True 10 | data: Optional[ResponseData] = None 11 | message: Optional[str] = None 12 | errors: Optional[list] = None 13 | 14 | def dict(self, *args, **kwargs) -> Dict[str, Any]: # type: ignore 15 | """Exclude `null` values from the response.""" 16 | kwargs.pop("exclude_none", None) 17 | return super().dict(*args, exclude_none=True, **kwargs) 18 | -------------------------------------------------------------------------------- /main/schemas/status.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Status(BaseModel): 5 | success: bool = True 6 | version: str 7 | message: str 8 | -------------------------------------------------------------------------------- /main/schemas/tasks.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel, Field, root_validator 4 | 5 | 6 | class TaskBase(BaseModel): 7 | title: str = Field(..., title="Task title") 8 | done: bool = Field(..., title="Task finish state") 9 | 10 | 11 | class Task(TaskBase): 12 | id: int 13 | 14 | 15 | class TaskInDB(Task): 16 | class Config: 17 | orm_mode = True 18 | fields_order = ["id", "title", "done"] 19 | 20 | @root_validator 21 | def reorder(cls, values: dict) -> dict: 22 | return {field: values[field] for field in cls.Config.fields_order} 23 | 24 | 25 | class TaskInCreate(BaseModel): 26 | title: str = Field(..., title="Task title") 27 | 28 | 29 | class TaskInUpdate(TaskBase): 30 | ... 31 | 32 | 33 | class TasksInDelete(BaseModel): 34 | ids: List[int] 35 | -------------------------------------------------------------------------------- /main/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, EmailStr 4 | 5 | 6 | class User(BaseModel): 7 | username: str 8 | email: EmailStr 9 | full_name: str 10 | 11 | 12 | class UserLogin(BaseModel): 13 | username: str 14 | password: str 15 | 16 | class Config: 17 | schema_extra = {"example": {"username": "user", "password": "weakpassword"}} 18 | 19 | 20 | class UserInCreate(User): 21 | password: str 22 | 23 | 24 | class UserInUpdate(User): 25 | password: Optional[str] = None 26 | 27 | 28 | class UserInDB(User): 29 | class Config: 30 | orm_mode = True 31 | 32 | 33 | class UserToken(BaseModel): 34 | token: str 35 | -------------------------------------------------------------------------------- /main/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-todo-example-app/26561d1953a01f74c0b72ebcd992c56d52db61b3/main/services/__init__.py -------------------------------------------------------------------------------- /main/services/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import Depends 4 | from fastapi.security import HTTPBasicCredentials 5 | from starlette.status import HTTP_401_UNAUTHORIZED 6 | 7 | from main.core.exceptions import ( 8 | InvalidUserCredentialsException, 9 | UserAlreadyExistException, 10 | UserNotFoundException, 11 | ) 12 | from main.core.logging import logger 13 | from main.core.security import get_basic_auth_token, verify_password 14 | from main.db.repositories.users import UsersRepository, get_users_repository 15 | from main.models.user import User 16 | from main.schemas.user import UserInCreate, UserLogin, UserToken 17 | 18 | 19 | class UserService: 20 | def __init__( 21 | self, user_repo: UsersRepository = Depends(get_users_repository) 22 | ) -> None: 23 | self.user_repo = user_repo 24 | 25 | def login_user(self, user: UserLogin) -> UserToken: 26 | """ 27 | Authenticate user with provided credentials. 28 | """ 29 | logger.info(f"Try to login user: {user.username}") 30 | self.authenticate(username=user.username, password=user.password) 31 | return UserToken( 32 | token=get_basic_auth_token(username=user.username, password=user.password) 33 | ) 34 | 35 | def register_user(self, user_create: UserInCreate) -> User: 36 | """ 37 | Register user in application. 38 | """ 39 | logger.info(f"Try to find user: {user_create.username}") 40 | db_user = self.user_repo.get_by_username(username=user_create.username) 41 | if db_user: 42 | raise UserAlreadyExistException( 43 | message=f"User with username: `{user_create.username}` already exists", 44 | status_code=HTTP_401_UNAUTHORIZED, 45 | ) 46 | logger.info(f"Creating user: {user_create.username}") 47 | user = self.user_repo.create(obj_create=user_create) 48 | return user 49 | 50 | def get_user(self, credentials: HTTPBasicCredentials) -> Optional[User]: 51 | """ 52 | Retrieve current user info by login credentials. 53 | """ 54 | logger.info(f"Getting user: {credentials.username}") 55 | return self.user_repo.get_by_username(username=credentials.username) 56 | 57 | def authenticate(self, username: str, password: str) -> User: 58 | """ 59 | Authenticate user. 60 | """ 61 | logger.info(f"Try to authenticate user: {username}") 62 | user = self.user_repo.get_by_username(username=username) 63 | if not user: 64 | raise UserNotFoundException( 65 | message=f"User with username: `{username}` not found", 66 | status_code=HTTP_401_UNAUTHORIZED, 67 | ) 68 | if not verify_password( 69 | plain_password=password, hashed_password=user.hashed_password 70 | ): 71 | raise InvalidUserCredentialsException( 72 | message="Invalid credentials", status_code=HTTP_401_UNAUTHORIZED 73 | ) 74 | return user 75 | 76 | def check_is_active(self, user: User) -> bool: 77 | """ 78 | Check if user account is active. 79 | """ 80 | return self.user_repo.is_active(user=user) 81 | -------------------------------------------------------------------------------- /main/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borys25ol/fastapi-todo-example-app/26561d1953a01f74c0b72ebcd992c56d52db61b3/main/utils/__init__.py -------------------------------------------------------------------------------- /main/utils/tasks.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | 4 | def generate_task_id() -> str: 5 | """ 6 | Return unique string for task id. 7 | """ 8 | return uuid.uuid4().hex 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py38'] 4 | skip-magic-trailing-comma = true 5 | include = '\.pyi?$' 6 | exclude = ''' 7 | /( 8 | \.eggs 9 | | \.git 10 | | \.hg 11 | | \.coverage 12 | | \.mypy_cache 13 | | \.tox 14 | | \.ve 15 | | _build 16 | | build 17 | | dist 18 | )/ 19 | ''' 20 | 21 | [tool.isort] 22 | line_length = 88 23 | multi_line_output = 3 24 | include_trailing_comma = true 25 | force_grid_wrap = 0 26 | ensure_newline_before_comments = true 27 | use_parentheses = true 28 | skip_gitignore = true 29 | skip_glob = ['.ve', '.git'] 30 | -------------------------------------------------------------------------------- /requirements-ci.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | black 3 | flake8 4 | isort 5 | mypy 6 | 7 | pre-commit 8 | pytest 9 | pytest-cov 10 | pytest-dependency 11 | pytest-order 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # python 3.10.4 2 | 3 | alembic==1.7.7 4 | fastapi==0.70.1 5 | passlib[bcrypt]==1.7.4 6 | psycopg2-binary==2.9.3 7 | pydantic[email]==1.9.0 8 | python-dotenv==0.19.2 9 | SQLAlchemy==1.4.35 10 | sqlalchemy-stubs==0.4 11 | tenacity==8.0.1 12 | uvicorn==0.16.0 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 15 3 | max-adjustable-complexity = 15 4 | max_cognitive_complexity = 15 5 | max-annotations-complexity = 4 6 | max-line-length = 88 7 | max_parameters_amount = 10 8 | ignore = 9 | F401, F403, F405, E501, E741, E999, F821, F901, W291, W504 10 | S101, S104, S105, S303, S106 11 | # P103 should be disabled since it threats non-format strings with braces (like default='{}') 12 | # all DXXX errors should be disabled because fuck forcing stupid docstrings everywhere 13 | W503, P103, D, N805, B008 14 | # black handles commas 15 | C812, C813, C815, C816 16 | # black handles wihtespace around operators 17 | E203 18 | # We use `id` for Session field names 19 | A003 20 | # Allow f-strings 21 | WPS305 22 | # Some unused WPS restrictions 23 | WPS111, WPS210, WPS326, WPS226, WPS602, WPS115, WPS432 24 | WPS110, WPS412, WPS338, WPS114, WPS331, WPS440, WPS214 25 | WPS323, WPS213, WPS211, WPS407, WPS306, WPS235, WPS237 26 | CCE001, WPS221, WPS202, WPS605, WPS204, WPS100, WPS601 27 | WPS317, WPS201, WPS606, WPS231, WPS232, WPS318, WPS118 28 | WPS431, WPS433, WPS337, WPS347, WPS615, WPS215, WPS348 29 | WPS352, WPS220, WPS230, WPS441, WPS410, WPS430, WPS437 30 | WPS442, WPS608, WPS404, WPS122, WPS420, WPS501, WPS529 31 | WPS428, WPS604, CCE002, WPS400 32 | # Fix single quotes 33 | Q000 34 | # Doc strings 35 | RST201,RST203,RST301 36 | # Exception maning 37 | N818 38 | exclude = 39 | env 40 | .git 41 | .ve 42 | setup.py 43 | Makefile 44 | README.md 45 | requirements.txt 46 | __pycache__ 47 | .DS_Store 48 | .isort, 49 | docker-compose.yml 50 | main/db/migrations/versions 51 | 52 | [mypy] 53 | python_version = 3.8 54 | ignore_missing_imports = True 55 | allow_redefinition = True 56 | warn_no_return = False 57 | check_untyped_defs = False 58 | disallow_untyped_defs = True 59 | warn_unused_ignores = True 60 | follow_imports = skip 61 | strict_optional = True 62 | exclude = .ve|env|logs 63 | plugins = sqlmypy 64 | 65 | [tool:pytest] 66 | norecursedirs=.ve 67 | addopts = -ra -q -s -v 68 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | __status__ = True 2 | __version__ = "1.0.0" 3 | __message__ = "FastAPI Todo Application" 4 | 5 | response = {"success": __status__, "version": __version__, "message": __message__} 6 | --------------------------------------------------------------------------------