├── .flake8 ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── alembic.ini ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── __main__.py │ ├── config │ │ ├── __init__.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── main.py │ │ └── parser │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── main.py │ ├── depends │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── db.py │ │ └── jwt.py │ ├── main_factory.py │ ├── middlewares │ │ ├── __init__.py │ │ └── metrics │ │ │ ├── __init__.py │ │ │ ├── labels.py │ │ │ ├── middleware.py │ │ │ └── utils.py │ ├── models │ │ ├── __init__.py │ │ ├── auth.py │ │ └── user.py │ ├── routes │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── default.py │ │ ├── exceptions.py │ │ ├── healthcheck.py │ │ ├── responses │ │ │ ├── __init__.py │ │ │ └── exceptions.py │ │ └── user.py │ └── services │ │ ├── __init__.py │ │ ├── auth.py │ │ └── user.py ├── common │ ├── __init__.py │ └── config │ │ ├── __init__.py │ │ ├── models │ │ ├── __init__.py │ │ ├── main.py │ │ └── paths.py │ │ └── parser │ │ ├── __init__.py │ │ ├── config_file_reader.py │ │ ├── logging_config.py │ │ ├── main.py │ │ └── paths.py ├── core │ ├── __init__.py │ ├── models │ │ ├── __init__.py │ │ └── dto │ │ │ ├── __init__.py │ │ │ └── user.py │ └── utils │ │ ├── __init__.py │ │ ├── datetime_utils.py │ │ └── exceptions.py └── infrastructure │ ├── __init__.py │ └── db │ ├── __init__.py │ ├── config │ ├── __init__.py │ ├── models │ │ ├── __init__.py │ │ └── db.py │ └── parser │ │ ├── __init__.py │ │ └── db.py │ ├── dao │ ├── __init__.py │ ├── holder.py │ └── rdb │ │ ├── __init__.py │ │ ├── base.py │ │ └── user.py │ ├── factory.py │ ├── migrations │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ └── 20230423-144800_198416ad5c1d_init.py │ └── models │ ├── __init__.py │ ├── base.py │ └── user.py ├── config ├── config.yml ├── logging.yml └── prod_config.yml ├── docker-compose.yaml ├── grafana.png ├── grafana ├── dashboards │ └── fastapi-metrics.json └── provisioning │ ├── dashboards │ └── dashboards.yaml │ └── datasources │ └── datasource.yml ├── loki └── config.yaml ├── poetry.lock ├── prometheus └── prometheus.yml ├── pyproject.toml ├── swagger.png ├── tests └── __init__.py └── vector └── vector.toml /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | count=False 3 | statistics=False 4 | show-source=False 5 | 6 | max-line-length=120 7 | 8 | application-import-names=dataclass_factory 9 | exclude= 10 | .venv, 11 | docs, 12 | benchmarks, 13 | app/infrastructure/db/migrations 14 | 15 | docstring-convention=pep257 16 | ignore= 17 | # A003 class attribute "..." is shadowing a python builtin 18 | A003, 19 | # D100 Missing docstring in public module 20 | D100, 21 | # D101 Missing docstring in public class 22 | D101, 23 | # D102 Missing docstring in public method 24 | D102, 25 | # D103 Missing docstring in public function 26 | D103, 27 | # D104 Missing docstring in public package 28 | D104, 29 | # D105 Missing docstring in magic method 30 | D105, 31 | # D107 Missing docstring in __init__ 32 | D107, 33 | # W503 line break before binary operator 34 | W503, 35 | # W504 line break after binary operator 36 | W504, 37 | # B008 Do not perform function calls in argument defaults. 38 | B008, 39 | 40 | max-cognitive-complexity=12 41 | max-complexity=12 42 | per-file-ignores= 43 | **/__init__.py:F401 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | *.py,cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | cover/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | .pybuilder/ 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | # For a library or package, you might want to ignore these files since the code is 86 | # intended to run in multiple environments; otherwise, check them in: 87 | # .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # poetry 97 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 98 | # This is especially recommended for binary packages to ensure reproducibility, and is more 99 | # commonly ignored for libraries. 100 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 101 | #poetry.lock 102 | 103 | # pdm 104 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 105 | #pdm.lock 106 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 107 | # in version control. 108 | # https://pdm.fming.dev/#use-with-ide 109 | .pdm.toml 110 | 111 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 112 | __pypackages__/ 113 | 114 | # Celery stuff 115 | celerybeat-schedule 116 | celerybeat.pid 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | # PyCharm 155 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 156 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 157 | # and can be added to the global gitignore or merged into this file. For a more nuclear 158 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 159 | .idea/ 160 | ./config/config.yml 161 | ./config/prod_config.yml 162 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-buster as python-base 2 | 3 | ENV PYTHONUNBUFFERED=1 \ 4 | PYTHONDONTWRITEBYTECODE=1 \ 5 | PIP_NO_CACHE_DIR=off \ 6 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 7 | PIP_DEFAULT_TIMEOUT=100 \ 8 | POETRY_HOME="/opt/poetry" \ 9 | POETRY_VIRTUALENVS_IN_PROJECT=true \ 10 | POETRY_NO_INTERACTION=1 \ 11 | PYSETUP_PATH="/opt/pysetup" \ 12 | VENV_PATH="/opt/pysetup/.venv" 13 | 14 | ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" 15 | 16 | 17 | FROM python-base as builder-base 18 | RUN apt-get update \ 19 | && apt-get install -y gcc git 20 | 21 | WORKDIR $PYSETUP_PATH 22 | COPY ./pyproject.toml . 23 | RUN pip install --no-cache-dir --upgrade pip \ 24 | && pip install --no-cache-dir setuptools wheel \ 25 | && pip install --no-cache-dir poetry 26 | 27 | RUN poetry install --no-dev 28 | 29 | FROM python-base as production 30 | COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH 31 | RUN apt-get update && apt-get install -y curl 32 | 33 | WORKDIR app/ 34 | COPY ./app /app/app 35 | CMD ["python", "-Om", "app.app.api"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 draincoder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | py := poetry run 2 | package_dir := app 3 | tests_dir := tests 4 | 5 | .PHONY: help 6 | help: 7 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 8 | 9 | .PHONY: install 10 | install: ## Install package with dependencies 11 | poetry install --with dev,test,lint 12 | 13 | .PHONY: lint 14 | lint: ## Lint code with flake8 15 | $(py) flake8 $(package_dir) --exit-zero 16 | 17 | .PHONY: test 18 | test: ## Run tests 19 | $(py) pytest $(tests_dir) 20 | 21 | .PHONY: run 22 | run: ## Run app 23 | $(py) python -m $(package_dir).api 24 | 25 | .PHONY: generate 26 | generate: ## Generate alembic migration (args: name="Init") 27 | alembic revision --m="${name}" --autogenerate 28 | 29 | .PHONY: migrate 30 | migrate: ## Migrate to new revision 31 | alembic upgrade head 32 | 33 | .PHONY: up 34 | up: ## Run app in docker container 35 | docker compose --profile api --profile grafana up --build -d 36 | 37 | .PHONY: down 38 | down: ## Stop docker containers 39 | docker compose --profile api --profile grafana down 40 | 41 | .PHONY: build 42 | build: ## Build docker image 43 | docker compose build 44 | 45 | .PHONY: migrate_docker 46 | migrate_docker: ## Run migration for postgres database 47 | docker compose --profile migration up --build 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Auth Template 2 | 3 | A small FastAPI template with OAuth authorization without using third-party authorization libraries and with simple CRUD operations 4 | 5 | ![List of endpoints](swagger.png) 6 | 7 | ### Quick start 8 | + `make migrate_docker` 9 | + `make up` 10 | 11 | 12 | ### Python libs stack 13 | + [FastAPI](https://fastapi.tiangolo.com/) — Async web framework for REST 14 | + [SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/) — ORM for working with database 15 | + [Alembic](https://alembic.sqlalchemy.org/en/latest/) — Database schema migration tool 16 | + [Pydantic](https://docs.pydantic.dev/) — Data validation and settings management 17 | 18 | ### Infrastructure 19 | + [Postgres](https://www.postgresql.org/docs/current/index.html) — Database 20 | + [Docker](https://docs.docker.com/) — For deployment 21 | 22 | ### Monitoring stack 23 | + [Grafana](https://grafana.com/docs/grafana/latest/) — Web view for logs 24 | + [Loki](https://grafana.com/docs/loki/latest/) — A platform to store and query logs 25 | + [Tempo](https://grafana.com/docs/tempo/latest/) — A high-volume distributed tracing backend 26 | + [Vector.dev*](https://vector.dev) — A tool to collect logs and send them to Loki 27 | + [Prometheus](https://prometheus.io/) - Monitoring system and time series database\ 28 | `* - in development` 29 | 30 | ![FastAPI Metrics](grafana.png) 31 | 32 | ### Check list 33 | - [x] Create template 34 | - [x] Configure Docker 35 | - [x] Configure monitoring 36 | - [ ] Add auto-tests 37 | 38 | 39 | ### Open source projects that have had an impact 40 | + [Shvatka](https://github.com/bomzheg/Shvatka) 41 | + [User Service](https://github.com/SamWarden/user_service) -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | # URI used to connect to database 3 | # The default value will be read from config.toml 4 | # sqlalchemy.url = driver://user:pass@localhost/dbname 5 | 6 | # path to migration scripts 7 | script_location = ./app/infrastructure/db/migrations 8 | # Format for migration versions filenames 9 | file_template = %%(year)d%%(month).2d%%(day).2d-%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s 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 migration/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" below. 39 | # version_locations = %(here)s/bar:%(here)s/bat:migration/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 44 | # Valid values for version_path_separator are: 45 | # 46 | # version_path_separator = : 47 | # version_path_separator = ; 48 | # version_path_separator = space 49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 50 | 51 | # the output encoding used when revision files 52 | # are written from script.py.mako 53 | # output_encoding = utf-8 54 | 55 | 56 | [post_write_hooks] 57 | # post_write_hooks defines scripts or Python functions that are run 58 | # on newly generated revision scripts. See the documentation for further 59 | # detail and examples 60 | 61 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 62 | # hooks = black 63 | # black.type = console_scripts 64 | # black.entrypoint = black 65 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 66 | 67 | # Logging configuration 68 | [loggers] 69 | keys = root,sqlalchemy,alembic 70 | 71 | [handlers] 72 | keys = console 73 | 74 | [formatters] 75 | keys = generic 76 | 77 | [logger_root] 78 | level = WARN 79 | handlers = console 80 | qualname = 81 | 82 | [logger_sqlalchemy] 83 | level = WARN 84 | handlers = 85 | qualname = sqlalchemy.engine 86 | 87 | [logger_alembic] 88 | level = INFO 89 | handlers = 90 | qualname = alembic 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(levelname)-5.5s [%(name)s] %(message)s 100 | datefmt = %H:%M:%S 101 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/__init__.py -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .__main__ import main 2 | 3 | 4 | __all__ = ["main"] 5 | -------------------------------------------------------------------------------- /app/api/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from sqlalchemy.orm import close_all_sessions 5 | 6 | from app.api.config.parser.main import load_app_config 7 | from app.api.main_factory import get_paths, run_api, init_api 8 | from app.common.config.parser import setup_logging 9 | from app.infrastructure.db.factory import create_engine, create_session_maker 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | async def main() -> None: 15 | paths = get_paths() 16 | 17 | setup_logging(paths) 18 | config = load_app_config(paths) 19 | engine = create_engine(config.db) 20 | pool = create_session_maker(engine) 21 | app = init_api(config.api.debug, pool, config.auth) 22 | logger.info("Started") 23 | try: 24 | await run_api(app, config.api, str(paths.logging_config_file)) 25 | finally: 26 | close_all_sessions() 27 | await engine.dispose() 28 | logger.info("Stopped") 29 | 30 | 31 | if __name__ == '__main__': 32 | asyncio.run(main()) 33 | -------------------------------------------------------------------------------- /app/api/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/api/config/__init__.py -------------------------------------------------------------------------------- /app/api/config/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/api/config/models/__init__.py -------------------------------------------------------------------------------- /app/api/config/models/api.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class ApiConfig: 6 | host: str 7 | port: int 8 | debug: bool 9 | 10 | 11 | @dataclass 12 | class AuthConfig: 13 | secret_key: str 14 | token_expire_minutes: int = 30 15 | -------------------------------------------------------------------------------- /app/api/config/models/main.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .api import AuthConfig, ApiConfig 4 | from app.common.config.models.main import Config 5 | 6 | 7 | @dataclass 8 | class AppConfig(Config): 9 | auth: AuthConfig 10 | api: ApiConfig 11 | 12 | @classmethod 13 | def from_base(cls, base: Config, auth: AuthConfig, api: ApiConfig): 14 | return cls( 15 | paths=base.paths, 16 | db=base.db, 17 | api=api, 18 | auth=auth 19 | ) 20 | -------------------------------------------------------------------------------- /app/api/config/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/api/config/parser/__init__.py -------------------------------------------------------------------------------- /app/api/config/parser/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from app.api.config.models.api import AuthConfig, ApiConfig 4 | 5 | 6 | def load_auth_config(dct: dict) -> AuthConfig: 7 | return AuthConfig( 8 | secret_key=dct["secret-key"], 9 | token_expire_minutes=dct["token-expire-minutes"] 10 | ) 11 | 12 | 13 | def load_api_config(dct: dict) -> ApiConfig: 14 | return ApiConfig( 15 | host=dct['host'], 16 | port=dct['port'], 17 | debug=dct['debug'] 18 | ) 19 | -------------------------------------------------------------------------------- /app/api/config/parser/main.py: -------------------------------------------------------------------------------- 1 | from app.api.config.models.main import AppConfig 2 | from app.api.config.parser.api import load_auth_config, load_api_config 3 | from app.common.config.models.paths import Paths 4 | from app.common.config.parser.config_file_reader import read_config 5 | from app.common.config.parser.main import load_config 6 | 7 | 8 | def load_app_config(paths: Paths) -> AppConfig: 9 | config_dct = read_config(paths) 10 | return AppConfig.from_base( 11 | base=load_config(paths, config_dct), 12 | api=load_api_config(config_dct['api']), 13 | auth=load_auth_config(config_dct['auth']) 14 | ) 15 | -------------------------------------------------------------------------------- /app/api/depends/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 3 | 4 | from .auth import get_current_user, get_current_db_user 5 | from .jwt import JWTProvider, jwt_provider 6 | from .db import DbProvider, dao_provider 7 | from app.api.config.models.api import AuthConfig 8 | 9 | 10 | def setup_providers(app: FastAPI, pool: async_sessionmaker[AsyncSession], auth_config: AuthConfig): 11 | db_provider = DbProvider(pool=pool) 12 | jwt_provider_ = JWTProvider(auth_config) 13 | 14 | app.dependency_overrides[dao_provider] = db_provider.dao 15 | app.dependency_overrides[jwt_provider] = lambda: jwt_provider_ 16 | app.dependency_overrides[get_current_user] = get_current_db_user 17 | -------------------------------------------------------------------------------- /app/api/depends/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import Depends, HTTPException 4 | from fastapi.security import OAuth2PasswordBearer 5 | from jose import JWTError 6 | from starlette import status 7 | 8 | from app.api.depends.db import dao_provider 9 | from app.api.depends.jwt import JWTProvider, jwt_provider 10 | from app.core.models import dto 11 | from app.core.utils.exceptions import NoUsernameFound 12 | from app.infrastructure.db.dao.holder import HolderDao 13 | 14 | logger = logging.getLogger(__name__) 15 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") 16 | 17 | 18 | def get_current_user(token: str = Depends(oauth2_scheme)) -> dto.User: # token only for authorize button in Swagger 19 | raise NotImplementedError 20 | 21 | 22 | async def get_current_db_user( 23 | jwt: JWTProvider = Depends(jwt_provider), 24 | token: str = Depends(oauth2_scheme), 25 | dao: HolderDao = Depends(dao_provider) 26 | ) -> dto.User: 27 | credentials_exception = HTTPException( 28 | status_code=status.HTTP_401_UNAUTHORIZED, 29 | detail="Could not validate credentials", 30 | headers={"WWW-Authenticate": "Bearer"}, 31 | ) 32 | try: 33 | username = jwt.get_payload_username(token) 34 | if username is None: 35 | raise credentials_exception 36 | except JWTError: 37 | raise credentials_exception 38 | try: 39 | user = await dao.user.get_by_username(username) 40 | except NoUsernameFound: 41 | raise credentials_exception 42 | return user 43 | -------------------------------------------------------------------------------- /app/api/depends/db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession 2 | 3 | from app.infrastructure.db.dao.holder import HolderDao 4 | 5 | 6 | def dao_provider() -> HolderDao: 7 | raise NotImplementedError 8 | 9 | 10 | class DbProvider: 11 | def __init__(self, pool: async_sessionmaker[AsyncSession]): 12 | self.pool = pool 13 | 14 | async def dao(self): 15 | async with self.pool() as session: 16 | yield HolderDao(session=session) 17 | -------------------------------------------------------------------------------- /app/api/depends/jwt.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from datetime import timedelta, datetime 5 | 6 | from fastapi.security import OAuth2PasswordBearer 7 | from jose import jwt 8 | from passlib.context import CryptContext 9 | 10 | from app.api.config.models.api import AuthConfig 11 | from app.api.models.auth import Token 12 | from app.core.models import dto 13 | from app.core.utils.datetime_utils import tz_utc 14 | 15 | logger = logging.getLogger(__name__) 16 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") 17 | 18 | 19 | def jwt_provider() -> JWTProvider: 20 | raise NotImplementedError 21 | 22 | 23 | class JWTProvider: 24 | def __init__(self, config: AuthConfig): 25 | self.config = config 26 | self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 27 | self.secret_key = config.secret_key 28 | self.algorythm = "HS256" 29 | self.access_token_expire = timedelta(minutes=config.token_expire_minutes) 30 | 31 | def verify_password(self, plain_password: str, hashed_password: str) -> bool: 32 | return self.pwd_context.verify(plain_password, hashed_password) 33 | 34 | def get_password_hash(self, password: str) -> str: 35 | return self.pwd_context.hash(password) 36 | 37 | def _create_access_token(self, data: dict, expires_delta: timedelta) -> Token: 38 | to_encode = data.copy() 39 | expire = datetime.now(tz=tz_utc) + expires_delta 40 | to_encode.update({"exp": expire}) 41 | encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorythm) 42 | return Token(access_token=encoded_jwt, token_type="bearer") 43 | 44 | def create_user_token(self, user: dto.User) -> Token: 45 | return self._create_access_token(data={"sub": user.username}, expires_delta=self.access_token_expire) 46 | 47 | def get_payload_username(self, token: str) -> str: 48 | payload = jwt.decode(token, self.secret_key, algorithms=[self.algorythm]) 49 | return payload.get("sub") 50 | -------------------------------------------------------------------------------- /app/api/main_factory.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import uvicorn 4 | from fastapi import FastAPI 5 | from fastapi.responses import ORJSONResponse 6 | from sqlalchemy.ext.asyncio import async_sessionmaker 7 | 8 | from app.api.config.models.api import AuthConfig, ApiConfig 9 | from app.api.depends import setup_providers 10 | from app.api.middlewares import setup_middlewares 11 | from app.api.routes import setup_routes 12 | from app.common.config.models.paths import Paths 13 | from app.common.config.parser.paths import common_get_paths 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def get_paths() -> Paths: 19 | return common_get_paths("API_PATH") 20 | 21 | 22 | def init_api(debug: bool, pool: async_sessionmaker, auth_config: AuthConfig) -> FastAPI: 23 | logger.debug("Initialize API") 24 | app = FastAPI(debug=debug, title="FastAPI Template", version="0.0.1", default_response_class=ORJSONResponse) 25 | setup_middlewares(app) 26 | setup_providers(app, pool, auth_config) 27 | setup_routes(app) 28 | return app 29 | 30 | 31 | async def run_api(app: FastAPI, api_config: ApiConfig, log_config: str) -> None: 32 | config = uvicorn.Config( 33 | app, host=api_config.host, 34 | port=api_config.port, 35 | log_level=logging.INFO, 36 | log_config=log_config 37 | ) 38 | server = uvicorn.Server(config) 39 | logger.info("Running API") 40 | await server.serve() 41 | -------------------------------------------------------------------------------- /app/api/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | 4 | from .metrics import add_metrics_middleware 5 | 6 | 7 | def setup_middlewares(app: FastAPI): 8 | add_metrics_middleware(app) 9 | app.add_middleware(CORSMiddleware, 10 | allow_origins=['*'], 11 | allow_credentials=True, 12 | allow_methods=["*"], 13 | allow_headers=["*"]) 14 | -------------------------------------------------------------------------------- /app/api/middlewares/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from .middleware import PrometheusMiddleware 4 | from .utils import setting_telemetry, metrics 5 | 6 | 7 | # TODO: Create config for Tempo 8 | APP_NAME = "api" 9 | GRPC_ENDPOINT = "http://auth_template.tempo:4317" 10 | 11 | 12 | def add_metrics_middleware(app: FastAPI): 13 | app.add_middleware(PrometheusMiddleware, app_name=APP_NAME) 14 | app.add_route("/metrics", metrics) 15 | setting_telemetry(app, APP_NAME, GRPC_ENDPOINT) 16 | -------------------------------------------------------------------------------- /app/api/middlewares/metrics/labels.py: -------------------------------------------------------------------------------- 1 | from prometheus_client import Counter, Gauge, Histogram 2 | 3 | INFO = Gauge( 4 | "fastapi_app_info", "FastAPI application information.", [ 5 | "app_name"] 6 | ) 7 | REQUESTS = Counter( 8 | "fastapi_requests_total", "Total count of requests by method and path.", [ 9 | "method", "path", "app_name"] 10 | ) 11 | RESPONSES = Counter( 12 | "fastapi_responses_total", 13 | "Total count of responses by method, path and status codes.", 14 | ["method", "path", "status_code", "app_name"], 15 | ) 16 | REQUESTS_PROCESSING_TIME = Histogram( 17 | "fastapi_requests_duration_seconds", 18 | "Histogram of requests processing time by path (in seconds)", 19 | ["method", "path", "app_name"], 20 | ) 21 | EXCEPTIONS = Counter( 22 | "fastapi_exceptions_total", 23 | "Total count of exceptions raised by path and exception type", 24 | ["method", "path", "exception_type", "app_name"], 25 | ) 26 | REQUESTS_IN_PROGRESS = Gauge( 27 | "fastapi_requests_in_progress", 28 | "Gauge of requests by method and path currently being processed", 29 | ["method", "path", "app_name"], 30 | ) 31 | -------------------------------------------------------------------------------- /app/api/middlewares/metrics/middleware.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Tuple 3 | 4 | from opentelemetry import trace 5 | from starlette.middleware.base import (BaseHTTPMiddleware, 6 | RequestResponseEndpoint) 7 | from starlette.requests import Request 8 | from starlette.responses import Response 9 | from starlette.routing import Match 10 | from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR 11 | from starlette.types import ASGIApp 12 | 13 | from .labels import (INFO, 14 | RESPONSES, 15 | REQUESTS_IN_PROGRESS, 16 | REQUESTS, 17 | REQUESTS_PROCESSING_TIME, 18 | EXCEPTIONS) 19 | 20 | 21 | class PrometheusMiddleware(BaseHTTPMiddleware): 22 | def __init__(self, app: ASGIApp, app_name: str = "FastAPI") -> None: 23 | super().__init__(app) 24 | self.app_name = app_name 25 | INFO.labels(app_name=self.app_name).inc() 26 | 27 | async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: 28 | status_code = HTTP_500_INTERNAL_SERVER_ERROR 29 | method = request.method 30 | path, is_handled_path = self.get_path(request) 31 | 32 | if not is_handled_path: 33 | return await call_next(request) 34 | 35 | REQUESTS_IN_PROGRESS.labels( 36 | method=method, path=path, app_name=self.app_name).inc() 37 | REQUESTS.labels(method=method, path=path, app_name=self.app_name).inc() 38 | before_time = time.perf_counter() 39 | try: 40 | response = await call_next(request) 41 | except BaseException as e: 42 | EXCEPTIONS.labels(method=method, path=path, exception_type=type( 43 | e).__name__, app_name=self.app_name).inc() 44 | raise e from None 45 | else: 46 | status_code = response.status_code 47 | after_time = time.perf_counter() 48 | span = trace.get_current_span() 49 | trace_id = trace.format_trace_id( 50 | span.get_span_context().trace_id) 51 | 52 | REQUESTS_PROCESSING_TIME.labels(method=method, path=path, app_name=self.app_name).observe( 53 | after_time - before_time, exemplar={"TraceID": trace_id} 54 | ) 55 | finally: 56 | RESPONSES.labels( 57 | method=method, 58 | path=path, 59 | status_code=status_code, 60 | app_name=self.app_name 61 | ).inc() 62 | REQUESTS_IN_PROGRESS.labels( 63 | method=method, path=path, app_name=self.app_name).dec() 64 | 65 | return response 66 | 67 | @staticmethod 68 | def get_path(request: Request) -> Tuple[str, bool]: 69 | for route in request.app.routes: 70 | match, child_scope = route.matches(request.scope) 71 | if match == Match.FULL: 72 | return route.path, True 73 | 74 | return request.url.path, False 75 | -------------------------------------------------------------------------------- /app/api/middlewares/metrics/utils.py: -------------------------------------------------------------------------------- 1 | from opentelemetry import trace 2 | from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter 3 | from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor 4 | from opentelemetry.instrumentation.logging import LoggingInstrumentor 5 | from opentelemetry.sdk.resources import Resource 6 | from opentelemetry.sdk.trace import TracerProvider 7 | from opentelemetry.sdk.trace.export import BatchSpanProcessor 8 | from prometheus_client import REGISTRY 9 | from prometheus_client.openmetrics.exposition import (CONTENT_TYPE_LATEST, 10 | generate_latest) 11 | from starlette.requests import Request 12 | from starlette.responses import Response 13 | from starlette.types import ASGIApp 14 | 15 | 16 | def metrics(request: Request) -> Response: 17 | return Response(generate_latest(REGISTRY), headers={"Content-Type": CONTENT_TYPE_LATEST}) 18 | 19 | 20 | def setting_telemetry(app: ASGIApp, app_name: str, endpoint: str, log_correlation: bool = True) -> None: 21 | resource = Resource.create(attributes={ 22 | "service.name": app_name, 23 | "compose_service": app_name 24 | }) 25 | 26 | tracer = TracerProvider(resource=resource) 27 | trace.set_tracer_provider(tracer) 28 | 29 | tracer.add_span_processor(BatchSpanProcessor( 30 | OTLPSpanExporter(endpoint=endpoint))) 31 | 32 | if log_correlation: 33 | LoggingInstrumentor().instrument(set_logging_format=True) 34 | 35 | FastAPIInstrumentor.instrument_app(app, tracer_provider=tracer) 36 | -------------------------------------------------------------------------------- /app/api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import Token 2 | from .user import UserRegister 3 | -------------------------------------------------------------------------------- /app/api/models/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | from app.core.models import dto 6 | 7 | 8 | class UserAuth(BaseModel): 9 | id: int 10 | auth_date: datetime 11 | email: str 12 | hash: str 13 | username: str | None = None 14 | 15 | def to_dto(self) -> dto.User: 16 | return dto.User( 17 | tg_id=self.id, 18 | username=self.username, 19 | email=self.email 20 | ) 21 | 22 | 23 | class Token(BaseModel): 24 | access_token: str 25 | token_type: str 26 | -------------------------------------------------------------------------------- /app/api/models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | from pydantic import BaseModel, validator 6 | 7 | from app.core.models import dto 8 | 9 | 10 | class UserRegister(BaseModel): 11 | username: str 12 | password: str 13 | email: str 14 | 15 | @validator('username') 16 | def username_validation(cls, v): 17 | pattern_username = re.compile(r'[A-Za-z][A-Za-z1-9_]+') 18 | if v == "": 19 | raise ValueError('Username is empty') 20 | if not pattern_username.match(v): 21 | raise ValueError('Wrong username format') 22 | if len(v) > 32: 23 | raise ValueError('Too long username') 24 | return v 25 | 26 | @validator('password') 27 | def password_validation(cls, v): 28 | pattern_password = re.compile(r'^(?=.*[0-9].*)(?=.*[a-z].*)(?=.*[A-Z].*)[0-9a-zA-Z]{8,}$') 29 | if not pattern_password.match(v): 30 | raise ValueError('Wrong password format') 31 | return v 32 | 33 | @validator('email') 34 | def email_validation(cls, v): 35 | pattern_email = re.compile(r"[^@]+@[^@]+\.[^@]+") 36 | if not pattern_email.match(v): 37 | raise ValueError('Wrong email format') 38 | return v 39 | 40 | def to_dto(self) -> dto.User: 41 | return dto.User( 42 | username=self.username, 43 | email=self.email 44 | ) 45 | -------------------------------------------------------------------------------- /app/api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from .default import default_router 4 | from .auth import auth_router 5 | from .user import user_router 6 | from .exceptions import setup_exception_handlers 7 | from .healthcheck import healthcheck_router 8 | 9 | 10 | def setup_routes(app: FastAPI) -> None: 11 | app.include_router(default_router) 12 | app.include_router(healthcheck_router) 13 | app.include_router(auth_router) 14 | app.include_router(user_router) 15 | setup_exception_handlers(app) 16 | -------------------------------------------------------------------------------- /app/api/routes/auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from fastapi.security import OAuth2PasswordRequestForm 3 | 4 | from app.api.depends import dao_provider, JWTProvider, jwt_provider 5 | from app.api.models import Token 6 | from app.api.services.auth import authenticate_user 7 | from app.infrastructure.db.dao.holder import HolderDao 8 | 9 | auth_router = APIRouter( 10 | prefix="/auth", 11 | tags=["Auth"], 12 | ) 13 | 14 | 15 | @auth_router.post( 16 | "/token", 17 | description="Create access token", 18 | response_model=Token, 19 | ) 20 | async def login(form_data: OAuth2PasswordRequestForm = Depends(), 21 | jwt: JWTProvider = Depends(jwt_provider), 22 | dao: HolderDao = Depends(dao_provider)) -> Token: 23 | user = await authenticate_user(form_data.username, form_data.password, dao, jwt) 24 | return jwt.create_user_token(user) 25 | -------------------------------------------------------------------------------- /app/api/routes/default.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from starlette import status 3 | from starlette.responses import RedirectResponse 4 | 5 | default_router = APIRouter( 6 | prefix="", 7 | tags=["Default"], 8 | include_in_schema=False, 9 | ) 10 | 11 | 12 | @default_router.get("/") 13 | async def default_redirect() -> RedirectResponse: 14 | return RedirectResponse('/docs', status_code=status.HTTP_302_FOUND) 15 | -------------------------------------------------------------------------------- /app/api/routes/exceptions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI 4 | from fastapi.responses import ORJSONResponse 5 | from starlette import status 6 | from starlette.requests import Request 7 | 8 | from app.api.routes.responses.exceptions import ErrorResult 9 | from app.core.utils.exceptions import (MultipleEmailFound, 10 | NoEmailFound, 11 | MultipleUsernameFound, 12 | NoUsernameFound, 13 | EmailExist, 14 | UsernameExist) 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def setup_exception_handlers(app: FastAPI) -> None: 20 | app.add_exception_handler(Exception, exception_handler) 21 | 22 | 23 | async def exception_handler(request: Request, err: Exception) -> ORJSONResponse: 24 | logger.error("Handle error", exc_info=err, extra={"error": err}) 25 | 26 | match err: 27 | case NoUsernameFound() | NoEmailFound() as err: 28 | return ORJSONResponse(ErrorResult(message=err.message, data=err), status_code=status.HTTP_404_NOT_FOUND) 29 | case MultipleUsernameFound() | MultipleEmailFound() | EmailExist() | UsernameExist() as err: 30 | return ORJSONResponse(ErrorResult(message=err.message, data=err), status_code=status.HTTP_409_CONFLICT) 31 | case _: 32 | logger.exception("Unknown error occurred", exc_info=err, extra={"error": err}) 33 | return ORJSONResponse( 34 | ErrorResult(message="Unknown server error has occurred", data=err), 35 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 36 | ) 37 | -------------------------------------------------------------------------------- /app/api/routes/healthcheck.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from fastapi import APIRouter, status 4 | 5 | healthcheck_router = APIRouter( 6 | prefix="/healthcheck", 7 | tags=["Healthcheck"], 8 | include_in_schema=False, 9 | ) 10 | 11 | 12 | @dataclass(frozen=True) 13 | class OkStatus: 14 | status: str = "ok" 15 | 16 | 17 | OK_STATUS = OkStatus() 18 | 19 | 20 | @healthcheck_router.get("/", status_code=status.HTTP_200_OK) 21 | async def get_status() -> OkStatus: 22 | return OK_STATUS 23 | -------------------------------------------------------------------------------- /app/api/routes/responses/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/api/routes/responses/__init__.py -------------------------------------------------------------------------------- /app/api/routes/responses/exceptions.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Generic, TypeVar 3 | 4 | from pydantic.generics import GenericModel 5 | 6 | TData = TypeVar("TData") 7 | 8 | 9 | @dataclass(frozen=True) 10 | class ErrorResult(GenericModel, Generic[TData]): 11 | message: str 12 | data: TData 13 | -------------------------------------------------------------------------------- /app/api/routes/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app.api.depends import dao_provider, JWTProvider, jwt_provider 4 | from app.api.depends.auth import get_current_user 5 | from app.api.models import UserRegister 6 | from app.api.services.user import create_new_user 7 | from app.core.models import dto 8 | from app.infrastructure.db.dao.holder import HolderDao 9 | 10 | user_router = APIRouter( 11 | prefix="/user", 12 | tags=["User"], 13 | ) 14 | 15 | 16 | @user_router.get( 17 | "/info", 18 | description="Get info about current user", 19 | response_model=dto.User, 20 | ) 21 | async def get_user_info(user: dto.User = Depends(get_current_user)) -> dto.User: 22 | return user 23 | 24 | 25 | @user_router.post( 26 | "/register", 27 | description="Create new user", 28 | response_model=dto.User, 29 | ) 30 | async def register_user(user: UserRegister, 31 | jwt: JWTProvider = Depends(jwt_provider), 32 | dao: HolderDao = Depends(dao_provider)) -> dto.User: 33 | return await create_new_user(user, jwt, dao) 34 | 35 | 36 | @user_router.get( 37 | "/@{username}", 38 | description="Get info about user by username", 39 | response_model=dto.User, 40 | ) 41 | async def get_user_by_id(username: str, dao: HolderDao = Depends(dao_provider)) -> dto.User: 42 | return await dao.user.get_by_username(username) 43 | 44 | 45 | @user_router.get( 46 | "/{email}", 47 | description="Get info about user by email", 48 | response_model=dto.User, 49 | ) 50 | async def get_user_by_email(email: str, dao: HolderDao = Depends(dao_provider)) -> dto.User: 51 | return await dao.user.get_by_email(email) 52 | -------------------------------------------------------------------------------- /app/api/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/api/services/__init__.py -------------------------------------------------------------------------------- /app/api/services/auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from starlette import status 3 | 4 | from app.api.depends import JWTProvider 5 | from app.core.models import dto 6 | from app.core.utils.exceptions import NoUsernameFound 7 | from app.infrastructure.db.dao.holder import HolderDao 8 | 9 | 10 | async def authenticate_user(username: str, password: str, dao: HolderDao, jwt: JWTProvider) -> dto.User: 11 | http_status_401 = HTTPException( 12 | status_code=status.HTTP_401_UNAUTHORIZED, 13 | detail="Incorrect username or password", 14 | headers={"WWW-Authenticate": "Bearer"}, 15 | ) 16 | try: 17 | user = await dao.user.get_by_username_with_password(username) 18 | except NoUsernameFound: 19 | raise http_status_401 20 | if not jwt.verify_password(password, user.hashed_password or ""): 21 | raise http_status_401 22 | return user.without_password() 23 | -------------------------------------------------------------------------------- /app/api/services/user.py: -------------------------------------------------------------------------------- 1 | from app.api.depends import JWTProvider 2 | from app.api.models import UserRegister 3 | from app.core.models import dto 4 | from app.infrastructure.db.dao.holder import HolderDao 5 | 6 | 7 | async def create_new_user( 8 | user: UserRegister, 9 | jwt: JWTProvider, 10 | dao: HolderDao 11 | ) -> dto.User: 12 | hashed_password = jwt.get_password_hash(user.password) 13 | user = await dao.user.create_user(user.to_dto().add_password(hashed_password)) 14 | await dao.commit() 15 | return user 16 | -------------------------------------------------------------------------------- /app/common/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import Paths, Config, setup_logging 2 | 3 | 4 | __all__ = [ 5 | "setup_logging", 6 | "Paths", 7 | "Config", 8 | ] 9 | -------------------------------------------------------------------------------- /app/common/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import Paths, Config 2 | from .parser import setup_logging 3 | 4 | __all__ = ["Paths", "Config", "setup_logging"] 5 | -------------------------------------------------------------------------------- /app/common/config/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import Config 2 | from .paths import Paths 3 | 4 | __all__ = ["Config", "Paths"] 5 | -------------------------------------------------------------------------------- /app/common/config/models/main.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from app.common.config.models.paths import Paths 4 | from app.infrastructure.db.config.models.db import DBConfig 5 | 6 | 7 | @dataclass 8 | class Config: 9 | paths: Paths 10 | db: DBConfig 11 | -------------------------------------------------------------------------------- /app/common/config/models/paths.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | 4 | 5 | @dataclass 6 | class Paths: 7 | app_dir: Path 8 | 9 | @property 10 | def config_path(self) -> Path: 11 | return self.app_dir / "config" 12 | 13 | @property 14 | def logging_config_file(self) -> Path: 15 | return self.config_path / "logging.yml" 16 | -------------------------------------------------------------------------------- /app/common/config/parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .logging_config import setup_logging 2 | 3 | __all__ = ["setup_logging"] 4 | -------------------------------------------------------------------------------- /app/common/config/parser/config_file_reader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import yaml 4 | 5 | from app.common.config.models.paths import Paths 6 | 7 | 8 | def read_config(paths: Paths) -> dict: 9 | if not (file := os.getenv("CONFIG_FILE")): 10 | file = "config.yml" 11 | with (paths.config_path / file).open("r") as f: 12 | return yaml.safe_load(f) 13 | -------------------------------------------------------------------------------- /app/common/config/parser/logging_config.py: -------------------------------------------------------------------------------- 1 | import logging.config 2 | 3 | import yaml 4 | 5 | from app.common.config.models.paths import Paths 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def setup_logging(paths: Paths): 11 | try: 12 | with paths.logging_config_file.open("r") as f: 13 | logging_config = yaml.safe_load(f) 14 | logging.config.dictConfig(logging_config) 15 | logger.info("Logging configured successfully") 16 | except IOError: 17 | logging.basicConfig(level=logging.DEBUG) 18 | logger.warning("Logging config file not found, use basic config") 19 | -------------------------------------------------------------------------------- /app/common/config/parser/main.py: -------------------------------------------------------------------------------- 1 | from app.common.config.models.main import Config 2 | from app.common.config.models.paths import Paths 3 | from app.infrastructure.db.config.parser.db import load_db_config 4 | 5 | 6 | def load_config(paths: Paths, config_dct: dict) -> Config: 7 | return Config( 8 | paths=paths, 9 | db=load_db_config(config_dct["db"]) 10 | ) 11 | -------------------------------------------------------------------------------- /app/common/config/parser/paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from app.common.config.models.paths import Paths 5 | 6 | 7 | def common_get_paths(env_var: str) -> Paths: 8 | if path := os.getenv(env_var): 9 | return Paths(Path(path)) 10 | return Paths(Path(__file__).parent.parent.parent.parent.parent) 11 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/core/__init__.py -------------------------------------------------------------------------------- /app/core/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/core/models/__init__.py -------------------------------------------------------------------------------- /app/core/models/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User, UserWithCreds # noqa: F401 2 | -------------------------------------------------------------------------------- /app/core/models/dto/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class User: 8 | db_id: int | None = None 9 | username: str | None = None 10 | email: str | None = None 11 | 12 | def add_password(self, hashed_password: str) -> UserWithCreds: 13 | return UserWithCreds( 14 | db_id=self.db_id, 15 | username=self.username, 16 | email=self.email, 17 | hashed_password=hashed_password, 18 | ) 19 | 20 | 21 | @dataclass 22 | class UserWithCreds(User): 23 | hashed_password: str | None = None 24 | 25 | def without_password(self) -> User: 26 | return User( 27 | db_id=self.db_id, 28 | username=self.username, 29 | email=self.email, 30 | ) 31 | -------------------------------------------------------------------------------- /app/core/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/core/utils/__init__.py -------------------------------------------------------------------------------- /app/core/utils/datetime_utils.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from datetime import datetime, tzinfo 3 | 4 | from dateutil import tz 5 | 6 | DATE_FORMAT = r"%d.%m.%y" 7 | DATE_FORMAT_USER = "dd.mm.yy" 8 | 9 | TIME_FORMAT = r"%H:%M" 10 | TIME_FORMAT_USER = "hh:mm" 11 | 12 | DATETIME_FORMAT = f"{DATE_FORMAT} {TIME_FORMAT}" 13 | DATETIME_FORMAT_USER = f"{DATE_FORMAT_USER} {TIME_FORMAT_USER}" 14 | 15 | GAME_LOCATION = "Europe/Moscow" 16 | 17 | 18 | def get_tz(location: str) -> tzinfo: 19 | result = tz.gettz(location) 20 | assert result is not None 21 | return result 22 | 23 | 24 | tz_game = get_tz(GAME_LOCATION) 25 | tz_utc = get_tz("UTC") 26 | tz_local = typing.cast(tzinfo, tz.gettz()) 27 | 28 | 29 | def add_timezone(dt: datetime, timezone: tzinfo = tz_game) -> datetime: 30 | return datetime.combine(dt.date(), dt.time(), timezone) 31 | -------------------------------------------------------------------------------- /app/core/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | class AppError(Exception): 5 | @property 6 | def message(self) -> str: 7 | return "An application error occurred" 8 | 9 | 10 | @dataclass(eq=False) 11 | class UsernameResolverError(AppError): 12 | username: str 13 | 14 | 15 | @dataclass(eq=False) 16 | class NoUsernameFound(UsernameResolverError): 17 | @property 18 | def message(self) -> str: 19 | return f"Can't find anything by '@{self.username}'" 20 | 21 | 22 | @dataclass(eq=False) 23 | class MultipleUsernameFound(UsernameResolverError): 24 | @property 25 | def message(self) -> str: 26 | return f"Exists any users for '@{self.username}'" 27 | 28 | 29 | @dataclass(eq=False) 30 | class UsernameExist(UsernameResolverError): 31 | @property 32 | def message(self) -> str: 33 | return f"A user with the '{self.username}' username already exists" 34 | 35 | 36 | @dataclass(eq=False) 37 | class EmailResolverError(AppError): 38 | email: str 39 | 40 | 41 | @dataclass(eq=False) 42 | class NoEmailFound(EmailResolverError): 43 | @property 44 | def message(self) -> str: 45 | return f"Can't find anything by '{self.email}'" 46 | 47 | 48 | @dataclass(eq=False) 49 | class MultipleEmailFound(EmailResolverError): 50 | @property 51 | def message(self) -> str: 52 | return f"Exists any users for '{self.email}'" 53 | 54 | 55 | @dataclass(eq=False) 56 | class EmailExist(EmailResolverError): 57 | @property 58 | def message(self) -> str: 59 | return f"A user with the '{self.email}' email already exists" 60 | 61 | 62 | class UnexpectedError(AppError): 63 | pass 64 | 65 | 66 | class CommitError(UnexpectedError): 67 | pass 68 | 69 | 70 | class RollbackError(UnexpectedError): 71 | pass 72 | 73 | 74 | class RepoError(UnexpectedError): 75 | pass 76 | -------------------------------------------------------------------------------- /app/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/infrastructure/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/infrastructure/db/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/db/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/infrastructure/db/config/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/db/config/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/infrastructure/db/config/models/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/db/config/models/db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | @dataclass 8 | class DBConfig: 9 | type: str | None = None 10 | connector: str | None = None 11 | host: str = "localhost" 12 | port: int = 5432 13 | login: str = "" 14 | password: str = "" 15 | name: str = "test" 16 | path: str | None = None 17 | echo: bool = False 18 | 19 | @property 20 | def uri(self): 21 | if self.type in ("mysql", "postgresql"): 22 | url = ( 23 | f"{self.type}+{self.connector}://" 24 | f"{self.login}:{self.password}" 25 | f"@{self.host}:{self.port}/{self.name}" 26 | ) 27 | elif self.type == "sqlite": 28 | url = f"{self.type}://{self.path}" 29 | else: 30 | raise ValueError("DB_TYPE not mysql, sqlite or postgres") 31 | logger.debug(url) 32 | return url 33 | -------------------------------------------------------------------------------- /app/infrastructure/db/config/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/infrastructure/db/config/parser/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/db/config/parser/db.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.db.config.models.db import DBConfig 2 | 3 | 4 | def load_db_config(db_dict: dict) -> DBConfig: 5 | return DBConfig( 6 | type=db_dict.get("type"), 7 | connector=db_dict.get("connector"), 8 | host=db_dict.get("host"), 9 | port=db_dict.get("port"), 10 | login=db_dict.get("login"), 11 | password=db_dict.get("password"), 12 | name=db_dict.get("name"), 13 | path=db_dict.get("path"), 14 | echo=db_dict.get("echo"), 15 | ) 16 | -------------------------------------------------------------------------------- /app/infrastructure/db/dao/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/draincoder/fastapi-template/88c7c673157efadc2a5133bf8f330ab4e4167da9/app/infrastructure/db/dao/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/db/dao/holder.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from app.infrastructure.db.dao.rdb import UserDao 4 | 5 | 6 | class HolderDao: 7 | def __init__(self, session: AsyncSession): 8 | self.session = session 9 | self.user = UserDao(self.session) 10 | 11 | async def commit(self): 12 | await self.session.commit() 13 | -------------------------------------------------------------------------------- /app/infrastructure/db/dao/rdb/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import UserDao 2 | -------------------------------------------------------------------------------- /app/infrastructure/db/dao/rdb/base.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Type, Generic, Sequence 2 | 3 | from sqlalchemy import delete, func, ScalarResult 4 | from sqlalchemy import select 5 | from sqlalchemy.exc import NoResultFound 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.orm.interfaces import ORMOption 8 | 9 | from app.infrastructure.db.models import BaseModel 10 | 11 | Model = TypeVar("Model", bound=BaseModel, covariant=True, contravariant=False) 12 | 13 | 14 | class BaseDAO(Generic[Model]): 15 | def __init__(self, model: Type[Model], session: AsyncSession): 16 | self.model = model 17 | self.session = session 18 | 19 | async def _get_all(self, options: Sequence[ORMOption] = tuple()) -> Sequence[Model]: 20 | result: ScalarResult[Model] = await self.session.scalars( 21 | select(self.model).options(*options) 22 | ) 23 | return result.all() 24 | 25 | async def _get_by_id( 26 | self, id_: int, options: Sequence[ORMOption] = None, populate_existing: bool = False 27 | ) -> Model: 28 | result = await self.session.get( 29 | self.model, id_, options=options, populate_existing=populate_existing 30 | ) 31 | if result is None: 32 | raise NoResultFound() 33 | return result 34 | 35 | def _save(self, obj: BaseModel): 36 | self.session.add(obj) 37 | 38 | async def delete_all(self): 39 | await self.session.execute(delete(self.model)) 40 | 41 | async def _delete(self, obj: BaseModel): 42 | await self.session.delete(obj) 43 | 44 | async def count(self): 45 | result = await self.session.execute(select(func.count(self.model.id))) 46 | return result.scalar_one() 47 | 48 | async def commit(self): 49 | await self.session.commit() 50 | 51 | async def _flush(self, *objects: BaseModel): 52 | await self.session.flush(objects) 53 | -------------------------------------------------------------------------------- /app/infrastructure/db/dao/rdb/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select, Result 2 | from sqlalchemy.exc import MultipleResultsFound, NoResultFound, DBAPIError, IntegrityError 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from app.core.models import dto 6 | from app.core.utils.exceptions import (MultipleUsernameFound, 7 | NoUsernameFound, 8 | UsernameExist, 9 | MultipleEmailFound, 10 | NoEmailFound, 11 | EmailExist, 12 | RepoError) 13 | from app.infrastructure.db.dao.rdb.base import BaseDAO 14 | from app.infrastructure.db.models import User 15 | 16 | 17 | class UserDao(BaseDAO[User]): 18 | def __init__(self, session: AsyncSession): 19 | super().__init__(User, session) 20 | 21 | async def get_by_username(self, username: str) -> dto.User: 22 | user = await self._get_by_username(username) 23 | return user.to_dto() 24 | 25 | async def _get_by_username(self, username: str) -> User: 26 | result: Result[tuple[User]] = await self.session.execute( 27 | select(User).where(User.username == username) 28 | ) 29 | try: 30 | user = result.scalar_one() 31 | except MultipleResultsFound as e: 32 | raise MultipleUsernameFound(username=username) from e 33 | except NoResultFound as e: 34 | raise NoUsernameFound(username=username) from e 35 | return user 36 | 37 | async def get_by_email(self, email: str) -> dto.User: 38 | user = await self._get_by_email(email) 39 | return user.to_dto() 40 | 41 | async def _get_by_email(self, email: str) -> User: 42 | result: Result[tuple[User]] = await self.session.execute( 43 | select(User).where(User.email == email) 44 | ) 45 | try: 46 | user = result.scalar_one() 47 | except MultipleResultsFound as e: 48 | raise MultipleEmailFound(email=email) from e 49 | except NoResultFound as e: 50 | raise NoEmailFound(email=email) from e 51 | return user 52 | 53 | async def get_by_username_with_password(self, username: str) -> dto.UserWithCreds: 54 | user = await self._get_by_username(username) 55 | return user.to_dto().add_password(user.hashed_password) 56 | 57 | async def set_password(self, user: dto.User, hashed_password: str): 58 | assert user.db_id 59 | db_user = await self._get_by_id(user.db_id) 60 | db_user.hashed_password = hashed_password 61 | user.hashed_password = hashed_password 62 | 63 | async def create_user(self, user: dto.UserWithCreds) -> dto.User: 64 | db_user = User( 65 | username=user.username, 66 | email=user.email, 67 | hashed_password=user.hashed_password 68 | ) 69 | self._save(db_user) 70 | try: 71 | await self.session.flush((db_user,)) 72 | except IntegrityError as err: 73 | self._parse_error(err, db_user) 74 | return db_user.to_dto() 75 | 76 | def _parse_error(self, err: DBAPIError, user: dto.User) -> None: 77 | match err.__cause__.__cause__.constraint_name: # type: ignore 78 | case "uq_users_email": 79 | raise EmailExist(user.email) from err 80 | case "uq_users_username": 81 | raise UsernameExist(user.username) from err 82 | case _: 83 | raise RepoError from err 84 | -------------------------------------------------------------------------------- /app/infrastructure/db/factory.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import make_url 2 | from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine 3 | 4 | from app.infrastructure.db.config.models.db import DBConfig 5 | 6 | 7 | def create_session_maker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: 8 | pool: async_sessionmaker[AsyncSession] = async_sessionmaker( 9 | bind=engine, expire_on_commit=False, autoflush=False 10 | ) 11 | return pool 12 | 13 | 14 | def create_engine(db_config: DBConfig) -> AsyncEngine: 15 | return create_async_engine(url=make_url(db_config.uri), echo=db_config.echo) 16 | -------------------------------------------------------------------------------- /app/infrastructure/db/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /app/infrastructure/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import engine_from_config, make_url 6 | from sqlalchemy import pool 7 | from sqlalchemy.engine import Connection 8 | from sqlalchemy.ext.asyncio import AsyncEngine 9 | 10 | from app.api.config.parser.main import load_config 11 | from app.api.main_factory import get_paths 12 | from app.common.config.parser.config_file_reader import read_config 13 | from app.infrastructure.db.models import BaseModel 14 | 15 | config = context.config 16 | 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | target_metadata = BaseModel.metadata 21 | 22 | if not (url := config.get_main_option("sqlalchemy.url")): 23 | paths = get_paths() 24 | config_dct = read_config(paths) 25 | url = make_url(load_config(paths, config_dct).db.uri) 26 | 27 | 28 | def run_migrations_offline() -> None: 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | context.configure( 41 | url=url, 42 | target_metadata=target_metadata, 43 | literal_binds=True, 44 | dialect_opts={"paramstyle": "named"}, 45 | ) 46 | 47 | with context.begin_transaction(): 48 | context.run_migrations() 49 | 50 | 51 | def do_run_migrations(connection: Connection) -> None: 52 | context.configure(connection=connection, target_metadata=target_metadata) 53 | 54 | with context.begin_transaction(): 55 | context.run_migrations() 56 | 57 | 58 | async def run_async_migrations() -> None: 59 | connectable = AsyncEngine( 60 | engine_from_config( 61 | config.get_section(config.config_ini_section), 62 | prefix="sqlalchemy.", 63 | poolclass=pool.NullPool, 64 | future=True, 65 | url=url, 66 | ) 67 | ) 68 | 69 | async with connectable.connect() as connection: 70 | await connection.run_sync(do_run_migrations) 71 | 72 | await connectable.dispose() 73 | 74 | 75 | def run_migrations_online() -> None: 76 | """Run migrations in 'online' mode. 77 | In this scenario we need to create an Engine 78 | and associate a connection with the context. 79 | """ 80 | connectable = config.attributes.get("connection", None) 81 | if connectable is None: 82 | asyncio.run(run_async_migrations()) 83 | else: 84 | do_run_migrations(connectable) 85 | 86 | 87 | if context.is_offline_mode(): 88 | run_migrations_offline() 89 | else: 90 | run_migrations_online() 91 | -------------------------------------------------------------------------------- /app/infrastructure/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() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /app/infrastructure/db/migrations/versions/20230423-144800_198416ad5c1d_init.py: -------------------------------------------------------------------------------- 1 | """Init 2 | 3 | Revision ID: 198416ad5c1d 4 | Revises: 5 | Create Date: 2023-04-23 14:48:00.380505 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '198416ad5c1d' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('id', sa.BigInteger(), nullable=False), 23 | sa.Column('username', sa.Text(), nullable=False), 24 | sa.Column('email', sa.Text(), nullable=False), 25 | sa.Column('hashed_password', sa.Text(), nullable=True), 26 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), 27 | sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), 28 | sa.PrimaryKeyConstraint('id', name=op.f('pk_users')), 29 | sa.UniqueConstraint('email', name=op.f('uq_users_email')), 30 | sa.UniqueConstraint('username', name=op.f('uq_users_username')) 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade() -> None: 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('users') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /app/infrastructure/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseModel 2 | from .user import User 3 | 4 | __all__ = ( 5 | "BaseModel", 6 | "User", 7 | ) 8 | -------------------------------------------------------------------------------- /app/infrastructure/db/models/base.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from sqlalchemy import MetaData, sql 4 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, registry 5 | 6 | convention = { 7 | "ix": "ix_%(column_0_label)s", 8 | "uq": "uq_%(table_name)s_%(column_0_N_name)s", 9 | "ck": "ck_%(table_name)s_%(constraint_name)s", 10 | "fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s", 11 | "pk": "pk_%(table_name)s", 12 | } 13 | 14 | mapper_registry = registry(metadata=MetaData(naming_convention=convention)) 15 | 16 | 17 | class BaseModel(DeclarativeBase): 18 | registry = mapper_registry 19 | metadata = mapper_registry.metadata 20 | 21 | 22 | class TimedBaseModel(BaseModel): 23 | __abstract__ = True 24 | 25 | created_at: Mapped[datetime.datetime] = mapped_column(nullable=False, server_default=sql.func.now()) 26 | updated_at: Mapped[datetime.datetime] = mapped_column( 27 | nullable=False, 28 | server_default=sql.func.now(), 29 | onupdate=sql.func.now(), 30 | ) 31 | -------------------------------------------------------------------------------- /app/infrastructure/db/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Text 2 | from sqlalchemy.orm import mapped_column, Mapped 3 | 4 | from .base import TimedBaseModel 5 | from app.core.models import dto 6 | 7 | 8 | class User(TimedBaseModel): 9 | __tablename__ = "users" 10 | __mapper_args__ = {"eager_defaults": True} 11 | 12 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 13 | username: Mapped[str] = mapped_column(Text, unique=True, nullable=False) 14 | email: Mapped[str] = mapped_column(Text, unique=True, nullable=False) 15 | hashed_password: Mapped[str] = mapped_column(Text, nullable=True) 16 | 17 | def __repr__(self): 18 | return f"" 19 | 20 | def to_dto(self) -> dto.User: 21 | return dto.User( 22 | db_id=self.id, 23 | username=self.username, 24 | email=self.email, 25 | ) 26 | -------------------------------------------------------------------------------- /config/config.yml: -------------------------------------------------------------------------------- 1 | db: 2 | type: postgresql 3 | connector: asyncpg 4 | host: localhost 5 | port: 5432 6 | login: postgres 7 | password: postgres 8 | name: postgres 9 | api: 10 | host: "127.0.0.1" 11 | port: 8000 12 | debug: false 13 | auth: 14 | secret-key: 3f219f0c414d60582932e9d85a6c03b7ef56555c01c82b6800d1201076d11d8b 15 | token-expire-minutes: 30 -------------------------------------------------------------------------------- /config/logging.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: false 3 | formatters: 4 | default: 5 | (): 'uvicorn.logging.DefaultFormatter' 6 | fmt: '{asctime} {levelname} [{module}:{lineno}:{funcName}] - {message}' 7 | style: "{" 8 | colored: 9 | (): colorlog.ColoredFormatter 10 | format: "{log_color}{asctime} {levelname} [{module}:{lineno}:{funcName}] - {white}{message}" 11 | style: "{" 12 | access: 13 | (): "uvicorn.logging.AccessFormatter" 14 | fmt: "{asctime} {levelname} [{name}] [{filename}:{lineno}] [trace_id={otelTraceID} span_id={otelSpanID} resource.service.name={otelServiceName}] - {message}" 15 | style: "{" 16 | handlers: 17 | default: 18 | class: logging.StreamHandler 19 | formatter: default 20 | stream: ext://sys.stderr 21 | console: 22 | class: logging.StreamHandler 23 | level: INFO 24 | formatter: colored 25 | stream: ext://sys.stdout 26 | access: 27 | class: logging.StreamHandler 28 | formatter: access 29 | stream: ext://sys.stdout 30 | loggers: 31 | uvicorn: 32 | level: INFO 33 | handlers: 34 | - default 35 | propagate: False 36 | uvicorn.error: 37 | level: INFO 38 | uvicorn.access: 39 | level: INFO 40 | propagate: False 41 | handlers: 42 | - access 43 | root: 44 | level: INFO 45 | handlers: [console] -------------------------------------------------------------------------------- /config/prod_config.yml: -------------------------------------------------------------------------------- 1 | db: 2 | type: postgresql 3 | connector: asyncpg 4 | host: auth_template.postgres 5 | port: 5432 6 | login: postgres 7 | password: postgres 8 | name: auth_template 9 | api: 10 | host: "0.0.0.0" 11 | port: 8000 12 | debug: false 13 | auth: 14 | secret-key: 3f219f0c414d60582932e9d85a6c03b7ef56555c01c82b6800d1201076d11d8b 15 | token-expire-minutes: 30 -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | x-logging: 4 | &default-logging 5 | driver: loki 6 | options: 7 | loki-url: 'http://localhost:3100/loki/api/v1/push' 8 | loki-pipeline-stages: | 9 | - multiline: 10 | firstline: '^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2}' 11 | max_wait_time: 3s 12 | - regex: 13 | expression: '^(?P