├── .dockerignore ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── alembic.ini ├── config ├── __init__.py ├── dev │ └── .gitkeep ├── local │ ├── .env.local │ ├── .secrets.toml │ ├── Dockerfile │ ├── config.toml │ ├── docker-compose.yaml │ └── export.toml ├── prod │ └── .gitkeep └── toml_config_manager.py ├── docs ├── Robert_Martin_CA.png ├── application_controller_interactor.svg ├── application_interactor.svg ├── application_interactor_adapter.svg ├── dep_graph_basic.svg ├── dep_graph_inv_correct.svg ├── dep_graph_inv_correct_di.svg ├── dep_graph_inv_corrupted.svg ├── domain_adapter.svg ├── draw.io │ ├── application_controller_interactor.drawio │ ├── application_interactor.drawio │ ├── application_interactor_adapter.drawio │ ├── dep_graph_basic.drawio │ ├── dep_graph_inv_correct.drawio │ ├── dep_graph_inv_correct_di.drawio │ ├── dep_graph_inv_corrupted.drawio │ ├── domain_adapter.drawio │ ├── identity_provider.drawio │ ├── infrastructure_controller_handler.drawio │ ├── infrastructure_handler.drawio │ └── toml_config_manager.drawio ├── handlers.png ├── identity_provider.svg ├── infrastructure_controller_handler.svg ├── infrastructure_handler.svg ├── onion_1.svg ├── onion_2.svg └── toml_config_manager.svg ├── pyproject.toml ├── scripts ├── dishka │ └── plot_dependencies_data.py └── makefile │ ├── docker_prune.sh │ └── pycache_del.sh ├── src └── app │ ├── __init__.py │ ├── application │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ ├── admin_create_user.py │ │ ├── admin_inactivate_user.py │ │ ├── admin_reactivate_user.py │ │ ├── super_admin_grant_admin.py │ │ ├── super_admin_revoke_admin.py │ │ └── user_change_password.py │ ├── common │ │ ├── __init__.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ ├── authorization.py │ │ │ ├── base.py │ │ │ ├── pagination.py │ │ │ └── sorting.py │ │ ├── ports │ │ │ ├── __init__.py │ │ │ ├── access_revoker.py │ │ │ ├── command_gateways │ │ │ │ ├── __init__.py │ │ │ │ └── user.py │ │ │ ├── identity_provider.py │ │ │ ├── query_gateways │ │ │ │ ├── __init__.py │ │ │ │ └── user.py │ │ │ └── transaction_manager.py │ │ ├── query_filters │ │ │ ├── __init__.py │ │ │ ├── sorting_order_enum.py │ │ │ └── user │ │ │ │ ├── __init__.py │ │ │ │ └── read_all.py │ │ ├── query_models │ │ │ ├── __init__.py │ │ │ └── user.py │ │ └── services │ │ │ ├── __init__.py │ │ │ ├── authorization.py │ │ │ └── current_user.py │ └── queries │ │ ├── __init__.py │ │ └── admin_list_users.py │ ├── domain │ ├── __init__.py │ ├── entities │ │ ├── __init__.py │ │ ├── base.py │ │ └── user.py │ ├── enums │ │ ├── __init__.py │ │ └── user_role.py │ ├── exceptions │ │ ├── __init__.py │ │ ├── base.py │ │ └── user.py │ ├── ports │ │ ├── __init__.py │ │ ├── password_hasher.py │ │ └── user_id_generator.py │ ├── services │ │ ├── __init__.py │ │ └── user.py │ └── value_objects │ │ ├── __init__.py │ │ ├── base.py │ │ ├── raw_password │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── raw_password.py │ │ └── validation.py │ │ ├── user_id.py │ │ ├── user_password_hash.py │ │ └── username │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── username.py │ │ └── validation.py │ ├── infrastructure │ ├── __init__.py │ ├── adapters │ │ ├── __init__.py │ │ ├── application │ │ │ ├── __init__.py │ │ │ ├── new_types.py │ │ │ ├── sqla_user_data_mapper.py │ │ │ ├── sqla_user_reader.py │ │ │ └── sqla_user_transaction_manager.py │ │ └── domain │ │ │ ├── __init__.py │ │ │ ├── bcrypt_password_hasher.py │ │ │ ├── new_types.py │ │ │ └── uuid_user_id_generator.py │ ├── auth_context │ │ ├── __init__.py │ │ ├── common │ │ │ ├── __init__.py │ │ │ ├── application_adapters │ │ │ │ ├── __init__.py │ │ │ │ ├── auth_session_access_revoker.py │ │ │ │ └── auth_session_identity_provider.py │ │ │ ├── auth_exceptions.py │ │ │ ├── auth_session.py │ │ │ ├── jwt_access_token_processor.py │ │ │ ├── managers │ │ │ │ ├── __init__.py │ │ │ │ ├── auth_session.py │ │ │ │ └── jwt_token.py │ │ │ ├── new_types.py │ │ │ ├── ports │ │ │ │ ├── __init__.py │ │ │ │ └── access_token_request_handler.py │ │ │ ├── sqla_auth_session_data_mapper.py │ │ │ ├── sqla_auth_transaction_manager.py │ │ │ ├── str_auth_session_id_generator.py │ │ │ └── utc_auth_session_timer.py │ │ ├── log_in.py │ │ ├── log_out.py │ │ └── sign_up.py │ ├── exceptions │ │ ├── __init__.py │ │ ├── base.py │ │ └── gateway_implementations.py │ └── sqla_persistence │ │ ├── __init__.py │ │ ├── alembic │ │ ├── README │ │ ├── __init__.py │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── 2025_05_24_1838-c8bc1a6d7a66_users_auth.py │ │ │ └── __init__.py │ │ ├── mappings │ │ ├── __init__.py │ │ ├── all.py │ │ ├── auth_context_session.py │ │ └── user.py │ │ └── orm_registry.py │ ├── presentation │ ├── __init__.py │ ├── common │ │ ├── __init__.py │ │ ├── asgi_auth_middleware.py │ │ ├── cookie_params.py │ │ ├── exception_handler.py │ │ ├── fastapi_dependencies.py │ │ ├── http_api_routers │ │ │ ├── __init__.py │ │ │ ├── account.py │ │ │ ├── api_v1.py │ │ │ ├── root.py │ │ │ └── user.py │ │ └── infrastructure_adapters │ │ │ ├── __init__.py │ │ │ └── auth_context │ │ │ ├── __init__.py │ │ │ └── cookie_access_token_request_handler.py │ └── http_controllers │ │ ├── __init__.py │ │ ├── account_log_in.py │ │ ├── account_log_out.py │ │ ├── account_sign_up.py │ │ ├── admin_create_user │ │ ├── __init__.py │ │ ├── controller.py │ │ └── pydantic_schema.py │ │ ├── admin_inactivate_user.py │ │ ├── admin_list_users │ │ ├── __init__.py │ │ ├── controller.py │ │ └── pydantic_schema.py │ │ ├── admin_reactivate_user.py │ │ ├── super_admin_grant_admin.py │ │ ├── super_admin_revoke_admin.py │ │ └── user_change_password.py │ ├── run.py │ └── setup │ ├── __init__.py │ ├── app_factory.py │ ├── config │ ├── __init__.py │ ├── constants.py │ ├── logs.py │ └── settings.py │ └── ioc │ ├── __init__.py │ ├── di_providers │ ├── __init__.py │ ├── application.py │ ├── domain.py │ ├── infrastructure.py │ └── settings.py │ └── registry.py ├── tests ├── __init__.py ├── performance │ ├── __init__.py │ └── profile_bcrypt_password_hasher.py └── unit │ ├── __init__.py │ ├── app │ ├── __init__.py │ ├── application │ │ ├── __init__.py │ │ └── common │ │ │ ├── __init__.py │ │ │ └── services │ │ │ ├── __init__.py │ │ │ ├── test_authorization.py │ │ │ └── test_current_user.py │ ├── domain │ │ ├── __init__.py │ │ ├── entities │ │ │ ├── __init__.py │ │ │ └── test_base.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ └── test_user.py │ │ └── value_objects │ │ │ ├── __init__.py │ │ │ ├── test_base.py │ │ │ ├── test_raw_password.py │ │ │ └── test_username.py │ ├── infrastructure │ │ ├── __init__.py │ │ └── adapters │ │ │ ├── __init__.py │ │ │ └── domain │ │ │ ├── __init__.py │ │ │ └── test_password_hasher_bcrypt.py │ └── setup │ │ ├── __init__.py │ │ └── config │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_constants.py │ │ ├── test_logs.py │ │ └── test_settings.py │ └── conftest.py └── uv.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Venv 2 | .venv/ 3 | 4 | # Git 5 | .git/ 6 | .gitignore 7 | .gitattributes 8 | 9 | # Python Cache 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | .mypy_cache/ 14 | .ruff_cache/ 15 | 16 | # IDE Specific 17 | .idea/ 18 | .vscode/ 19 | 20 | # Test artifacts 21 | .coverage 22 | htmlcov/ 23 | 24 | # Documentation and non-essential files 25 | docs/ 26 | scripts/ 27 | LICENSE 28 | logo.jpg 29 | 30 | # Local environment 31 | .env* -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | build-and-test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Set up Python 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: 3.12.0 15 | 16 | - name: Install UV and dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | python -m pip install uv==0.5.7 20 | uv pip install -e '.[dev,test]' --system 21 | 22 | - name: Format code 23 | run: | 24 | ruff format 25 | 26 | - name: Lint code 27 | run: | 28 | ruff check --exit-non-zero-on-fix 29 | mypy 30 | 31 | - name: Test code 32 | run: | 33 | pytest -v 34 | 35 | - name: Test Docker Compose setup 36 | run: | 37 | export APP_ENV=local 38 | python config/toml_config_manager.py 39 | cd config/local 40 | echo "Generated .env.local content:" 41 | cat .env.local 42 | export COMPOSE_ENV_FILES=.env.local 43 | docker compose up -d --build 44 | 45 | - name: Verify Application Health 46 | run: | 47 | timeout 10s bash -c ' 48 | while ! curl -sf http://0.0.0.0:9999/api/v1/; do 49 | sleep 1 50 | done 51 | ' 52 | 53 | - name: Test Signup Handler 54 | run: | 55 | timeout 10s bash -c " 56 | while ! curl -sf -X POST \ 57 | 'http://0.0.0.0:9999/api/v1/account/signup' \ 58 | -H 'accept: application/json' \ 59 | -H 'Content-Type: application/json' \ 60 | -d '{ 61 | \"username\": \"string\", 62 | \"password\": \"string\" 63 | }'; do 64 | sleep 1 65 | done 66 | " 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | # build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | # Config 163 | config/dev/* 164 | !config/dev/.gitkeep 165 | config/prod/* 166 | !config/prod/.gitkeep 167 | .secrets.* 168 | .env.* 169 | 170 | # IgnoreToDo 171 | todo/ 172 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: make-check 5 | name: source-code-check 6 | entry: make code.check 7 | language: system 8 | pass_filenames: false 9 | always_run: true 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ivan Borovets 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Environment 2 | PYTHON := python 3 | CONFIGS_DIG := config 4 | TOML_CONFIG_MANAGER := $(CONFIGS_DIG)/toml_config_manager.py 5 | 6 | .PHONY: env dotenv 7 | env: 8 | @echo APP_ENV=$(APP_ENV) 9 | 10 | dotenv: 11 | @$(PYTHON) $(TOML_CONFIG_MANAGER) ${APP_ENV} 12 | 13 | # Docker compose 14 | DOCKER_COMPOSE := docker compose 15 | DOCKER_COMPOSE_PRUNE := scripts/makefile/docker_prune.sh 16 | 17 | .PHONY: guard-APP_ENV up.db up.db-echo up up.echo down down.total logs.db shell.db prune 18 | guard-APP_ENV: 19 | ifndef APP_ENV 20 | $(error "APP_ENV is not set. Set APP_ENV before running this command.") 21 | endif 22 | 23 | up.db: guard-APP_ENV 24 | @echo "APP_ENV=$(APP_ENV)" 25 | @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up -d web_app_db_pg --build 26 | 27 | up.db-echo: guard-APP_ENV 28 | @echo "APP_ENV=$(APP_ENV)" 29 | @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up web_app_db_pg --build 30 | 31 | up: 32 | @echo "APP_ENV=$(APP_ENV)" 33 | @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up -d --build 34 | 35 | up.echo: 36 | @echo "APP_ENV=$(APP_ENV)" 37 | @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up --build 38 | 39 | down: guard-APP_ENV 40 | @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) down 41 | 42 | down.total: guard-APP_ENV 43 | @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) down -v 44 | 45 | logs.db: 46 | @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) logs -f web_app_db_pg 47 | 48 | shell.db: 49 | @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) exec web_app_db_pg sh 50 | 51 | prune: 52 | $(DOCKER_COMPOSE_PRUNE) 53 | 54 | # Code quality 55 | .PHONY: code.format code.lint code.test code.cov code.cov.html code.check 56 | code.format: 57 | ruff format 58 | 59 | code.lint: code.format 60 | ruff check --exit-non-zero-on-fix 61 | mypy 62 | 63 | code.test: 64 | pytest -v 65 | 66 | code.cov: 67 | coverage run -m pytest 68 | coverage combine 69 | coverage report 70 | 71 | code.cov.html: 72 | coverage run -m pytest 73 | coverage combine 74 | coverage html 75 | 76 | code.check: code.lint code.test 77 | 78 | # Project structure visualization 79 | PYCACHE_DEL := scripts/makefile/pycache_del.sh 80 | DISHKA_PLOT_DATA := scripts/dishka/plot_dependencies_data.py 81 | 82 | .PHONY: pycache-del tree plot-data 83 | pycache-del: 84 | @$(PYCACHE_DEL) 85 | 86 | # Clean tree 87 | tree: pycache-del 88 | @tree 89 | 90 | # Dishka 91 | plot-data: 92 | python $(DISHKA_PLOT_DATA) 93 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts. 5 | # Use forward slashes (/) also on windows to provide an os agnostic path 6 | script_location = src/app/infrastructure/sqla_persistence/alembic 7 | 8 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 9 | # Uncomment the line below if you want the files to be prepended with date and time 10 | file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 11 | 12 | # sys.path path, will be prepended to sys.path if present. 13 | # defaults to the current working directory. 14 | prepend_sys_path = . 15 | 16 | # timezone to use when rendering the date within the migration file 17 | # as well as the filename. 18 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 19 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 20 | # string value is passed to ZoneInfo() 21 | # leave blank for localtime 22 | # timezone = 23 | 24 | # max length of characters to apply to the "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to src/app/infra/sqla_db/alembic/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:src/app/infra/sqla_db/alembic/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from script.py.mako 59 | # output_encoding = utf-8 60 | 61 | sqlalchemy.url = driver://user:pass@localhost/dbname 62 | 63 | 64 | [post_write_hooks] 65 | # post_write_hooks defines scripts or Python functions that are run 66 | # on newly generated revision scripts. See the documentation for further 67 | # detail and examples 68 | 69 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 70 | # hooks = black 71 | # black.type = console_scripts 72 | # black.entrypoint = black 73 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 74 | 75 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 76 | hooks = ruff 77 | ruff.type = exec 78 | ruff.executable = %(here)s/.venv/bin/ruff 79 | ruff.options = format REVISION_SCRIPT_FILENAME 80 | 81 | # Logging configuration 82 | [loggers] 83 | keys = root,sqlalchemy,alembic 84 | 85 | [handlers] 86 | keys = console 87 | 88 | [formatters] 89 | keys = generic 90 | 91 | [logger_root] 92 | level = WARN 93 | handlers = console 94 | qualname = 95 | 96 | [logger_sqlalchemy] 97 | level = WARN 98 | handlers = 99 | qualname = sqlalchemy.engine 100 | 101 | [logger_alembic] 102 | level = INFO 103 | handlers = 104 | qualname = alembic 105 | 106 | [handler_console] 107 | class = StreamHandler 108 | args = (sys.stderr,) 109 | level = NOTSET 110 | formatter = generic 111 | 112 | [formatter_generic] 113 | format = %(levelname)-5.5s [%(name)s] %(message)s 114 | datefmt = %H:%M:%S 115 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/config/__init__.py -------------------------------------------------------------------------------- /config/dev/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/config/dev/.gitkeep -------------------------------------------------------------------------------- /config/local/.env.local: -------------------------------------------------------------------------------- 1 | # This .env file was automatically generated by toml_config_manager. 2 | # Do not edit directly. Make changes in config.toml or .secrets.toml instead. 3 | # Ensure values here match those in config files. 4 | # Environment: local 5 | # Generated: 2025-03-21T21:22:58.860798+00:00 6 | POSTGRES_USER=postgres 7 | POSTGRES_PASSWORD=changethis 8 | POSTGRES_DB=web_app_db_pg 9 | POSTGRES_PORT=5432 10 | UVICORN_HOST=0.0.0.0 11 | UVICORN_PORT=9999 12 | -------------------------------------------------------------------------------- /config/local/.secrets.toml: -------------------------------------------------------------------------------- 1 | # PostgreSQL 2 | [postgres] 3 | USER = "postgres" 4 | PASSWORD = "changethis" 5 | 6 | [security.password] 7 | # Critical: This value must be kept secret and should be changed in production 8 | # Losing or changing this value will invalidate all existing password hashes 9 | # IMPORTANT: Replace the placeholder below with your own secure random string 10 | # Recommended: Use a cryptographically secure random generator to create a 11 | # string of at least 32 characters including numbers, letters, and symbols 12 | PEPPER = "REPLACE_THIS_WITH_YOUR_OWN_SECRET_PEPPER_VALUE" 13 | 14 | [security.auth] 15 | # Recommended: Use a cryptographically secure random generator to create a 16 | # string of at least 32 characters including numbers, letters, and symbols 17 | JWT_SECRET = "REPLACE_THIS_WITH_YOUR_OWN_SECRET_VALUE" 18 | # JWT_ALGORITHM can be set to "HS256", "HS384", "HS512", "RS256", "RS384", "RS512" 19 | JWT_ALGORITHM = "HS256" 20 | # SESSION_TTL_MIN must be at least 1 (number of minutes) 21 | SESSION_TTL_MIN = 5 22 | # SESSION_REFRESH_THRESHOLD must be a number (fraction, 0 < fraction < 1) 23 | SESSION_REFRESH_THRESHOLD = 0.2 24 | 25 | [security.cookies] 26 | # Secure can be set to 0 or 1 27 | # Choose 1 for production (secure=True, samesite="Strict") 28 | SECURE = 0 29 | -------------------------------------------------------------------------------- /config/local/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder 2 | WORKDIR /app 3 | ENV UV_COMPILE_BYTECODE=1 \ 4 | UV_LINK_MODE=copy 5 | COPY pyproject.toml README.md ./ 6 | COPY src/ ./src/ 7 | RUN --mount=type=cache,target=/root/.cache/uv \ 8 | uv pip install --system --target /app/dependencies . 9 | COPY . ./ 10 | 11 | FROM python:3.12-slim-bookworm AS final 12 | ARG APP_UID=10001 13 | ARG APP_GID=10001 14 | RUN groupadd -g ${APP_GID} appgroup && \ 15 | useradd -u ${APP_UID} -g ${APP_GID} -s /usr/sbin/nologin -M appuser 16 | WORKDIR /app 17 | ENV PYTHONDONTWRITEBYTECODE=1 \ 18 | PYTHONUNBUFFERED=1 \ 19 | PYTHONPATH="/app/dependencies" \ 20 | PATH="/app/dependencies/bin:$PATH" 21 | COPY --from=builder --chown=${APP_UID}:${APP_GID} /app/ ./ 22 | USER appuser 23 | EXPOSE 8888 24 | CMD ["uvicorn", "app.run:make_app", "--host", "0.0.0.0", "--port", "8888", "--loop", "uvloop"] 25 | -------------------------------------------------------------------------------- /config/local/config.toml: -------------------------------------------------------------------------------- 1 | # PostgreSQL 2 | [postgres] 3 | DB = "web_app_db_pg" 4 | HOST = "localhost" 5 | PORT = 5432 6 | DRIVER = "psycopg" 7 | 8 | # Uvicorn 9 | [uvicorn] 10 | HOST = "0.0.0.0" 11 | PORT = 9999 12 | 13 | # SQLAlchemy 14 | [sqla] 15 | ECHO = false 16 | ECHO_POOL = false 17 | POOL_SIZE = 50 18 | MAX_OVERFLOW = 10 19 | 20 | # Logs 21 | [logs] 22 | # Level can be set to "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" 23 | LEVEL = "DEBUG" 24 | -------------------------------------------------------------------------------- /config/local/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web_app_db_pg: 3 | container_name: "app_db_pg_${APP_ENV}" 4 | image: postgres:16-alpine 5 | shm_size: 128mb 6 | environment: 7 | - POSTGRES_USER=${POSTGRES_USER} 8 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 9 | - POSTGRES_DB=${POSTGRES_DB} 10 | ports: 11 | - "${POSTGRES_PORT}:5432" 12 | volumes: 13 | - pgdata:/var/lib/postgresql/data 14 | healthcheck: 15 | test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] 16 | interval: 5s 17 | timeout: 5s 18 | retries: 10 19 | start_period: 10s 20 | 21 | web_app: 22 | build: 23 | context: ../.. 24 | dockerfile: config/${APP_ENV}/Dockerfile 25 | image: web_app:latest 26 | environment: 27 | APP_ENV: ${APP_ENV} 28 | UVICORN_HOST: ${UVICORN_HOST} 29 | UVICORN_PORT: ${UVICORN_PORT} 30 | POSTGRES_HOST: web_app_db_pg 31 | ports: 32 | - "${UVICORN_PORT}:${UVICORN_PORT}" 33 | depends_on: 34 | web_app_db_pg: 35 | condition: service_healthy 36 | command: > 37 | sh -c " 38 | echo 'Running alembic migrations...' && 39 | alembic upgrade head && 40 | echo 'Starting Uvicorn...' && 41 | uvicorn app.run:make_app --host ${UVICORN_HOST} --port ${UVICORN_PORT} --loop uvloop 42 | " 43 | 44 | volumes: 45 | pgdata: 46 | -------------------------------------------------------------------------------- /config/local/export.toml: -------------------------------------------------------------------------------- 1 | # Don't rename `export` or `fields` 2 | [export] 3 | fields = [ 4 | # PostgreSQL from secrets 5 | "postgres.USER", 6 | "postgres.PASSWORD", 7 | # PostgreSQL from config 8 | "postgres.DB", 9 | "postgres.PORT", 10 | # Uvicorn from config 11 | "uvicorn.HOST", 12 | "uvicorn.PORT", 13 | ] 14 | -------------------------------------------------------------------------------- /config/prod/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/config/prod/.gitkeep -------------------------------------------------------------------------------- /docs/Robert_Martin_CA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/docs/Robert_Martin_CA.png -------------------------------------------------------------------------------- /docs/draw.io/application_controller_interactor.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/draw.io/dep_graph_basic.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /docs/draw.io/dep_graph_inv_correct.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/draw.io/dep_graph_inv_corrupted.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /docs/draw.io/domain_adapter.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/draw.io/infrastructure_controller_handler.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/handlers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/docs/handlers.png -------------------------------------------------------------------------------- /scripts/dishka/plot_dependencies_data.py: -------------------------------------------------------------------------------- 1 | import dishka.plotter 2 | import uvloop 3 | from dishka import AsyncContainer, make_async_container 4 | 5 | from app.setup.config.settings import AppSettings, load_settings 6 | from app.setup.ioc.registry import get_providers 7 | 8 | 9 | def make_plot_data_container(settings: AppSettings) -> AsyncContainer: 10 | return make_async_container(*get_providers(), context={AppSettings: settings}) 11 | 12 | 13 | def generate_dependency_graph_d2(container: AsyncContainer) -> str: 14 | """ 15 | Generates a dependency graph for the container in `d2` format. 16 | See https://d2lang.com for rendering instructions. 17 | """ 18 | return dishka.plotter.render_d2(container) 19 | 20 | 21 | async def main() -> None: 22 | settings: AppSettings = load_settings() 23 | async with make_plot_data_container(settings)() as container: 24 | print(generate_dependency_graph_d2(container)) 25 | await container.close() 26 | 27 | 28 | if __name__ == "__main__": 29 | uvloop.run(main()) 30 | -------------------------------------------------------------------------------- /scripts/makefile/docker_prune.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # The script is called from Makefile 3 | echo "Warning: This will remove all unused containers, networks, images, and volumes." 4 | echo "Are you sure you want to continue? [y/N]" 5 | read -r response 6 | if [ "$response" = "y" ] || [ "$response" = "Y" ]; then 7 | docker system prune -a --volumes 8 | else 9 | echo "Operation cancelled." 10 | fi 11 | -------------------------------------------------------------------------------- /scripts/makefile/pycache_del.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | find . | grep -E "(/__pycache__$|\.pyc$|\.pyo$)" | xargs rm -rf 3 | -------------------------------------------------------------------------------- /src/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/__init__.py -------------------------------------------------------------------------------- /src/app/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/__init__.py -------------------------------------------------------------------------------- /src/app/application/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/commands/__init__.py -------------------------------------------------------------------------------- /src/app/application/commands/admin_create_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from typing import TypedDict 4 | from uuid import UUID 5 | 6 | from app.application.common.ports.command_gateways.user import UserCommandGateway 7 | from app.application.common.ports.transaction_manager import TransactionManager 8 | from app.application.common.services.authorization import AuthorizationService 9 | from app.application.common.services.current_user import CurrentUserService 10 | from app.domain.enums.user_role import UserRole 11 | from app.domain.exceptions.user import UsernameAlreadyExistsError 12 | from app.domain.services.user import UserService 13 | from app.domain.value_objects.raw_password.raw_password import RawPassword 14 | from app.domain.value_objects.username.username import Username 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | @dataclass(frozen=True, slots=True, kw_only=True) 20 | class CreateUserRequest: 21 | username: str 22 | password: str 23 | role: UserRole 24 | 25 | 26 | class CreateUserResponse(TypedDict): 27 | id: UUID 28 | 29 | 30 | class CreateUserInteractor: 31 | """ 32 | :raises AuthenticationError: 33 | :raises DataMapperError: 34 | :raises AuthorizationError: 35 | :raises DomainFieldError: 36 | :raises UsernameAlreadyExists: 37 | """ 38 | 39 | def __init__( 40 | self, 41 | current_user_service: CurrentUserService, 42 | authorization_service: AuthorizationService, 43 | user_command_gateway: UserCommandGateway, 44 | user_service: UserService, 45 | transaction_manager: TransactionManager, 46 | ): 47 | self._current_user_service = current_user_service 48 | self._authorization_service = authorization_service 49 | self._user_command_gateway = user_command_gateway 50 | self._user_service = user_service 51 | self._transaction_manager = transaction_manager 52 | 53 | async def __call__(self, request_data: CreateUserRequest) -> CreateUserResponse: 54 | log.info( 55 | "Create user by admin: started. Username: '%s'.", 56 | request_data.username, 57 | ) 58 | 59 | current_user = await self._current_user_service.get_current_user() 60 | self._authorization_service.authorize_for_subordinate_role( 61 | current_user, 62 | target_role=request_data.role, 63 | ) 64 | 65 | username = Username(request_data.username) 66 | password = RawPassword(request_data.password) 67 | user = self._user_service.create_user(username, password) 68 | user.role = request_data.role 69 | self._user_command_gateway.add(user) 70 | 71 | try: 72 | await self._transaction_manager.flush() 73 | except UsernameAlreadyExistsError: 74 | raise 75 | 76 | await self._transaction_manager.commit() 77 | 78 | log.info("Create user by admin: finished. Username: '%s'.", user.username.value) 79 | return CreateUserResponse(id=user.id_.value) 80 | -------------------------------------------------------------------------------- /src/app/application/commands/admin_inactivate_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from app.application.common.ports.access_revoker import AccessRevoker 5 | from app.application.common.ports.command_gateways.user import UserCommandGateway 6 | from app.application.common.ports.transaction_manager import TransactionManager 7 | from app.application.common.services.authorization import AuthorizationService 8 | from app.application.common.services.current_user import CurrentUserService 9 | from app.domain.entities.user import User 10 | from app.domain.enums.user_role import UserRole 11 | from app.domain.exceptions.user import UserNotFoundByUsernameError 12 | from app.domain.services.user import UserService 13 | from app.domain.value_objects.username.username import Username 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | @dataclass(frozen=True, slots=True) 19 | class InactivateUserRequest: 20 | username: str 21 | 22 | 23 | class InactivateUserInteractor: 24 | """ 25 | :raises AuthenticationError: 26 | :raises DataMapperError: 27 | :raises AuthorizationError: 28 | :raises DomainFieldError: 29 | :raises UserNotFoundByUsername: 30 | :raises ActivationChangeNotPermitted: 31 | """ 32 | 33 | def __init__( 34 | self, 35 | current_user_service: CurrentUserService, 36 | authorization_service: AuthorizationService, 37 | user_command_gateway: UserCommandGateway, 38 | user_service: UserService, 39 | transaction_manager: TransactionManager, 40 | access_revoker: AccessRevoker, 41 | ): 42 | self._current_user_service = current_user_service 43 | self._authorization_service = authorization_service 44 | self._user_command_gateway = user_command_gateway 45 | self._user_service = user_service 46 | self._transaction_manager = transaction_manager 47 | self._access_revoker = access_revoker 48 | 49 | async def __call__(self, request_data: InactivateUserRequest) -> None: 50 | log.info( 51 | "Inactivate user by admin: started. Username: '%s'.", 52 | request_data.username, 53 | ) 54 | 55 | current_user = await self._current_user_service.get_current_user() 56 | self._authorization_service.authorize_for_subordinate_role( 57 | current_user, 58 | target_role=UserRole.USER, 59 | ) 60 | 61 | username = Username(request_data.username) 62 | user: User | None = await self._user_command_gateway.read_by_username( 63 | username, 64 | for_update=True, 65 | ) 66 | if user is None: 67 | raise UserNotFoundByUsernameError(username) 68 | 69 | self._authorization_service.authorize_for_subordinate_role( 70 | current_user, 71 | target_role=user.role, 72 | ) 73 | 74 | self._user_service.toggle_user_activation(user, is_active=False) 75 | await self._transaction_manager.commit() 76 | await self._access_revoker.remove_all_user_access(user.id_) 77 | 78 | log.info( 79 | "Inactivate user by admin: finished. Username: '%s'.", 80 | user.username.value, 81 | ) 82 | -------------------------------------------------------------------------------- /src/app/application/commands/admin_reactivate_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from app.application.common.ports.command_gateways.user import UserCommandGateway 5 | from app.application.common.ports.transaction_manager import TransactionManager 6 | from app.application.common.services.authorization import AuthorizationService 7 | from app.application.common.services.current_user import CurrentUserService 8 | from app.domain.entities.user import User 9 | from app.domain.enums.user_role import UserRole 10 | from app.domain.exceptions.user import UserNotFoundByUsernameError 11 | from app.domain.services.user import UserService 12 | from app.domain.value_objects.username.username import Username 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | @dataclass(frozen=True, slots=True) 18 | class ReactivateUserRequest: 19 | username: str 20 | 21 | 22 | class ReactivateUserInteractor: 23 | """ 24 | :raises AuthenticationError: 25 | :raises DataMapperError: 26 | :raises AuthorizationError: 27 | :raises DomainFieldError: 28 | :raises UserNotFoundByUsername: 29 | :raises ActivationChangeNotPermitted: 30 | """ 31 | 32 | def __init__( 33 | self, 34 | current_user_service: CurrentUserService, 35 | authorization_service: AuthorizationService, 36 | user_command_gateway: UserCommandGateway, 37 | user_service: UserService, 38 | transaction_manager: TransactionManager, 39 | ): 40 | self._current_user_service = current_user_service 41 | self._authorization_service = authorization_service 42 | self._user_command_gateway = user_command_gateway 43 | self._user_service = user_service 44 | self._transaction_manager = transaction_manager 45 | 46 | async def __call__(self, request_data: ReactivateUserRequest) -> None: 47 | log.info( 48 | "Reactivate user by admin: started. Username: '%s'.", 49 | request_data.username, 50 | ) 51 | 52 | current_user = await self._current_user_service.get_current_user() 53 | self._authorization_service.authorize_for_subordinate_role( 54 | current_user, 55 | target_role=UserRole.USER, 56 | ) 57 | 58 | username = Username(request_data.username) 59 | user: User | None = await self._user_command_gateway.read_by_username( 60 | username, 61 | for_update=True, 62 | ) 63 | if user is None: 64 | raise UserNotFoundByUsernameError(username) 65 | 66 | self._authorization_service.authorize_for_subordinate_role( 67 | current_user, 68 | target_role=user.role, 69 | ) 70 | 71 | self._user_service.toggle_user_activation(user, is_active=True) 72 | await self._transaction_manager.commit() 73 | 74 | log.info( 75 | "Reactivate user by admin: finished. Username: '%s'.", 76 | user.username.value, 77 | ) 78 | -------------------------------------------------------------------------------- /src/app/application/commands/super_admin_grant_admin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from app.application.common.ports.command_gateways.user import UserCommandGateway 5 | from app.application.common.ports.transaction_manager import TransactionManager 6 | from app.application.common.services.authorization import AuthorizationService 7 | from app.application.common.services.current_user import CurrentUserService 8 | from app.domain.entities.user import User 9 | from app.domain.enums.user_role import UserRole 10 | from app.domain.exceptions.user import UserNotFoundByUsernameError 11 | from app.domain.services.user import UserService 12 | from app.domain.value_objects.username.username import Username 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | @dataclass(frozen=True, slots=True) 18 | class GrantAdminRequest: 19 | username: str 20 | 21 | 22 | class GrantAdminInteractor: 23 | """ 24 | :raises AuthenticationError: 25 | :raises DataMapperError: 26 | :raises AuthorizationError: 27 | :raises DomainFieldError: 28 | :raises UserNotFoundByUsername: 29 | :raises RoleChangeNotPermitted: 30 | """ 31 | 32 | def __init__( 33 | self, 34 | current_user_service: CurrentUserService, 35 | authorization_service: AuthorizationService, 36 | user_command_gateway: UserCommandGateway, 37 | user_service: UserService, 38 | transaction_manager: TransactionManager, 39 | ): 40 | self._current_user_service = current_user_service 41 | self._authorization_service = authorization_service 42 | self._user_command_gateway = user_command_gateway 43 | self._user_service = user_service 44 | self._transaction_manager = transaction_manager 45 | 46 | async def __call__(self, request_data: GrantAdminRequest) -> None: 47 | log.info( 48 | "Grant admin by admin: started. Username: '%s'.", 49 | request_data.username, 50 | ) 51 | 52 | current_user = await self._current_user_service.get_current_user() 53 | self._authorization_service.authorize_for_subordinate_role( 54 | current_user, 55 | target_role=UserRole.ADMIN, 56 | ) 57 | 58 | username = Username(request_data.username) 59 | user: User | None = await self._user_command_gateway.read_by_username( 60 | username, 61 | for_update=True, 62 | ) 63 | if user is None: 64 | raise UserNotFoundByUsernameError(username) 65 | 66 | self._user_service.toggle_user_admin_role(user, is_admin=True) 67 | await self._transaction_manager.commit() 68 | 69 | log.info("Grant admin by admin: finished. Username: '%s'.", user.username.value) 70 | -------------------------------------------------------------------------------- /src/app/application/commands/super_admin_revoke_admin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from app.application.common.ports.command_gateways.user import UserCommandGateway 5 | from app.application.common.ports.transaction_manager import TransactionManager 6 | from app.application.common.services.authorization import AuthorizationService 7 | from app.application.common.services.current_user import CurrentUserService 8 | from app.domain.entities.user import User 9 | from app.domain.enums.user_role import UserRole 10 | from app.domain.exceptions.user import UserNotFoundByUsernameError 11 | from app.domain.services.user import UserService 12 | from app.domain.value_objects.username.username import Username 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | @dataclass(frozen=True, slots=True) 18 | class RevokeAdminRequest: 19 | username: str 20 | 21 | 22 | class RevokeAdminInteractor: 23 | """ 24 | :raises AuthenticationError: 25 | :raises DataMapperError: 26 | :raises AuthorizationError: 27 | :raises DomainFieldError: 28 | :raises UserNotFoundByUsername: 29 | :raises RoleChangeNotPermitted: 30 | """ 31 | 32 | def __init__( 33 | self, 34 | current_user_service: CurrentUserService, 35 | authorization_service: AuthorizationService, 36 | user_command_gateway: UserCommandGateway, 37 | user_service: UserService, 38 | transaction_manager: TransactionManager, 39 | ): 40 | self._current_user_service = current_user_service 41 | self._authorization_service = authorization_service 42 | self._user_command_gateway = user_command_gateway 43 | self._user_service = user_service 44 | self._transaction_manager = transaction_manager 45 | 46 | async def __call__(self, request_data: RevokeAdminRequest) -> None: 47 | log.info( 48 | "Revoke admin by admin: started. Username: '%s'.", 49 | request_data.username, 50 | ) 51 | 52 | current_user = await self._current_user_service.get_current_user() 53 | self._authorization_service.authorize_for_subordinate_role( 54 | current_user, 55 | target_role=UserRole.ADMIN, 56 | ) 57 | 58 | username = Username(request_data.username) 59 | user: User | None = await self._user_command_gateway.read_by_username( 60 | username, 61 | for_update=True, 62 | ) 63 | if user is None: 64 | raise UserNotFoundByUsernameError(username) 65 | 66 | self._user_service.toggle_user_admin_role(user, is_admin=False) 67 | await self._transaction_manager.commit() 68 | 69 | log.info( 70 | "Revoke admin by admin: finished. Username: '%s'.", 71 | user.username.value, 72 | ) 73 | -------------------------------------------------------------------------------- /src/app/application/commands/user_change_password.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from app.application.common.exceptions.authorization import AuthorizationError 5 | from app.application.common.ports.command_gateways.user import UserCommandGateway 6 | from app.application.common.ports.transaction_manager import TransactionManager 7 | from app.application.common.services.authorization import AuthorizationService 8 | from app.application.common.services.current_user import CurrentUserService 9 | from app.domain.entities.user import User 10 | from app.domain.exceptions.user import UserNotFoundByUsernameError 11 | from app.domain.services.user import UserService 12 | from app.domain.value_objects.raw_password.raw_password import RawPassword 13 | from app.domain.value_objects.username.username import Username 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | @dataclass(frozen=True, slots=True, kw_only=True) 19 | class ChangePasswordRequest: 20 | username: str 21 | password: str 22 | 23 | 24 | class ChangePasswordInteractor: 25 | """ 26 | :raises AuthenticationError: 27 | :raises DataMapperError: 28 | :raises AuthorizationError: 29 | :raises DomainFieldError: 30 | :raises UserNotFoundByUsername: 31 | """ 32 | 33 | def __init__( 34 | self, 35 | current_user_service: CurrentUserService, 36 | authorization_service: AuthorizationService, 37 | user_command_gateway: UserCommandGateway, 38 | user_service: UserService, 39 | transaction_manager: TransactionManager, 40 | ): 41 | self._current_user_service = current_user_service 42 | self._authorization_service = authorization_service 43 | self._user_command_gateway = user_command_gateway 44 | self._user_service = user_service 45 | self._transaction_manager = transaction_manager 46 | 47 | async def __call__(self, request_data: ChangePasswordRequest) -> None: 48 | log.info("Change password: started.") 49 | 50 | current_user = await self._current_user_service.get_current_user() 51 | 52 | username = Username(request_data.username) 53 | password = RawPassword(request_data.password) 54 | user: User | None = await self._user_command_gateway.read_by_username( 55 | username, 56 | for_update=True, 57 | ) 58 | if user is None: 59 | raise UserNotFoundByUsernameError(username) 60 | 61 | try: 62 | self._authorization_service.authorize_for_self( 63 | current_user, 64 | target_user=user, 65 | ) 66 | except AuthorizationError: 67 | self._authorization_service.authorize_for_subordinate_role( 68 | current_user, 69 | target_role=user.role, 70 | ) 71 | 72 | self._user_service.change_password(user, password) 73 | await self._transaction_manager.commit() 74 | 75 | log.info("Change password: finished.") 76 | -------------------------------------------------------------------------------- /src/app/application/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/exceptions/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/exceptions/authorization.py: -------------------------------------------------------------------------------- 1 | from app.application.common.exceptions.base import ApplicationError 2 | 3 | 4 | class AuthorizationError(ApplicationError): 5 | pass 6 | -------------------------------------------------------------------------------- /src/app/application/common/exceptions/base.py: -------------------------------------------------------------------------------- 1 | class ApplicationError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/app/application/common/exceptions/pagination.py: -------------------------------------------------------------------------------- 1 | from app.application.common.exceptions.base import ApplicationError 2 | 3 | 4 | class PaginationError(ApplicationError): 5 | pass 6 | -------------------------------------------------------------------------------- /src/app/application/common/exceptions/sorting.py: -------------------------------------------------------------------------------- 1 | from app.application.common.exceptions.base import ApplicationError 2 | 3 | 4 | class SortingError(ApplicationError): 5 | pass 6 | -------------------------------------------------------------------------------- /src/app/application/common/ports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/ports/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/ports/access_revoker.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from app.domain.value_objects.user_id import UserId 5 | 6 | 7 | class AccessRevoker(Protocol): 8 | @abstractmethod 9 | async def remove_all_user_access(self, user_id: UserId) -> None: 10 | """ 11 | :raises DataMapperError: 12 | """ 13 | -------------------------------------------------------------------------------- /src/app/application/common/ports/command_gateways/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/ports/command_gateways/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/ports/command_gateways/user.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from app.domain.entities.user import User 5 | from app.domain.value_objects.user_id import UserId 6 | from app.domain.value_objects.username.username import Username 7 | 8 | 9 | class UserCommandGateway(Protocol): 10 | @abstractmethod 11 | def add(self, user: User) -> None: 12 | """ 13 | :raises DataMapperError: 14 | """ 15 | 16 | @abstractmethod 17 | async def read_by_id(self, user_id: UserId) -> User | None: 18 | """ 19 | :raises DataMapperError: 20 | """ 21 | 22 | @abstractmethod 23 | async def read_by_username( 24 | self, 25 | username: Username, 26 | for_update: bool = False, 27 | ) -> User | None: 28 | """ 29 | :raises DataMapperError: 30 | """ 31 | -------------------------------------------------------------------------------- /src/app/application/common/ports/identity_provider.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from app.domain.value_objects.user_id import UserId 5 | 6 | 7 | class IdentityProvider(Protocol): 8 | @abstractmethod 9 | async def get_current_user_id(self) -> UserId: 10 | """ 11 | :raises AuthenticationError: 12 | """ 13 | -------------------------------------------------------------------------------- /src/app/application/common/ports/query_gateways/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/ports/query_gateways/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/ports/query_gateways/user.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from app.application.common.query_filters.user.read_all import UserReadAllParams 5 | from app.application.common.query_models.user import UserQueryModel 6 | 7 | 8 | class UserQueryGateway(Protocol): 9 | @abstractmethod 10 | async def read_all( 11 | self, 12 | user_read_all_params: UserReadAllParams, 13 | ) -> list[UserQueryModel] | None: 14 | """ 15 | :raises ReaderError: 16 | """ 17 | -------------------------------------------------------------------------------- /src/app/application/common/ports/transaction_manager.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | 5 | class TransactionManager(Protocol): 6 | """ 7 | Transaction Manager is an UOW-compatible interface for 8 | flushing and committing changes to the data source. 9 | The actual implementation of UOW can be bundled with an ORM, 10 | like SQLAlchemy's session. 11 | """ 12 | 13 | @abstractmethod 14 | async def flush(self) -> None: 15 | """ 16 | :raises DataMapperError: 17 | :raises UsernameAlreadyExists: 18 | 19 | Mostly to check data source constraints. 20 | """ 21 | 22 | @abstractmethod 23 | async def commit(self) -> None: 24 | """ 25 | :raises DataMapperError: 26 | 27 | Persist changes to the data source. 28 | """ 29 | -------------------------------------------------------------------------------- /src/app/application/common/query_filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/query_filters/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/query_filters/sorting_order_enum.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class SortingOrder(StrEnum): 5 | ASC = "ASC" 6 | DESC = "DESC" 7 | -------------------------------------------------------------------------------- /src/app/application/common/query_filters/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/query_filters/user/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/query_filters/user/read_all.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from app.application.common.exceptions.pagination import PaginationError 4 | from app.application.common.query_filters.sorting_order_enum import SortingOrder 5 | 6 | 7 | @dataclass(frozen=True, slots=True, kw_only=True) 8 | class UserReadAllPagination: 9 | """ 10 | :raises PaginationError: 11 | """ 12 | 13 | limit: int 14 | offset: int 15 | 16 | def __post_init__(self): 17 | if self.limit <= 0: 18 | raise PaginationError(f"Limit must be greater than 0, got {self.limit}") 19 | if self.offset < 0: 20 | raise PaginationError(f"Offset must be non-negative, got {self.offset}") 21 | 22 | 23 | @dataclass(frozen=True, slots=True, kw_only=True) 24 | class UserReadAllSorting: 25 | sorting_field: str 26 | sorting_order: SortingOrder 27 | 28 | 29 | @dataclass(frozen=True, slots=True) 30 | class UserReadAllParams: 31 | pagination: UserReadAllPagination 32 | sorting: UserReadAllSorting 33 | -------------------------------------------------------------------------------- /src/app/application/common/query_models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/query_models/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/query_models/user.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | from uuid import UUID 3 | 4 | from app.domain.enums.user_role import UserRole 5 | 6 | 7 | class UserQueryModel(TypedDict): 8 | id_: UUID 9 | username: str 10 | role: UserRole 11 | is_active: bool 12 | -------------------------------------------------------------------------------- /src/app/application/common/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/common/services/__init__.py -------------------------------------------------------------------------------- /src/app/application/common/services/authorization.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from typing import Final 3 | 4 | from app.application.common.exceptions.authorization import AuthorizationError 5 | from app.domain.entities.user import User 6 | from app.domain.enums.user_role import UserRole 7 | 8 | SUBORDINATE_ROLES: Final[Mapping[UserRole, set[UserRole]]] = { 9 | UserRole.SUPER_ADMIN: {UserRole.ADMIN, UserRole.USER}, 10 | UserRole.ADMIN: {UserRole.USER}, 11 | UserRole.USER: set(), 12 | } 13 | 14 | 15 | class AuthorizationService: 16 | def authorize_for_self(self, current_user: User, /, *, target_user: User) -> None: 17 | """ 18 | :raises AuthorizationError: 19 | """ 20 | if current_user.id_ != target_user.id_: 21 | raise AuthorizationError("Not authorized.") 22 | 23 | def authorize_for_subordinate_role( 24 | self, 25 | current_user: User, 26 | /, 27 | *, 28 | target_role: UserRole, 29 | ) -> None: 30 | """ 31 | :raises AuthorizationError: 32 | """ 33 | allowed_roles = SUBORDINATE_ROLES.get(current_user.role, set()) 34 | if target_role not in allowed_roles: 35 | raise AuthorizationError("Not authorized.") 36 | -------------------------------------------------------------------------------- /src/app/application/common/services/current_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.application.common.exceptions.authorization import AuthorizationError 4 | from app.application.common.ports.command_gateways.user import UserCommandGateway 5 | from app.application.common.ports.identity_provider import IdentityProvider 6 | from app.domain.entities.user import User 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class CurrentUserService: 12 | def __init__( 13 | self, 14 | identity_provider: IdentityProvider, 15 | user_command_gateway: UserCommandGateway, 16 | ): 17 | self._identity_provider = identity_provider 18 | self._user_command_gateway = user_command_gateway 19 | 20 | async def get_current_user(self) -> User: 21 | """ 22 | :raises AuthenticationError: 23 | :raises DataMapperError: 24 | :raises AuthorizationError: 25 | """ 26 | current_user_id = await self._identity_provider.get_current_user_id() 27 | user: User | None = await self._user_command_gateway.read_by_id(current_user_id) 28 | if user is None: 29 | log.debug("Failed to retrieve current user.") 30 | raise AuthorizationError("Not authorized.") 31 | return user 32 | -------------------------------------------------------------------------------- /src/app/application/queries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/application/queries/__init__.py -------------------------------------------------------------------------------- /src/app/application/queries/admin_list_users.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from typing import TypedDict 4 | 5 | from app.application.common.exceptions.sorting import SortingError 6 | from app.application.common.ports.query_gateways.user import UserQueryGateway 7 | from app.application.common.query_filters.sorting_order_enum import SortingOrder 8 | from app.application.common.query_filters.user.read_all import ( 9 | UserReadAllPagination, 10 | UserReadAllParams, 11 | UserReadAllSorting, 12 | ) 13 | from app.application.common.query_models.user import UserQueryModel 14 | from app.application.common.services.authorization import AuthorizationService 15 | from app.application.common.services.current_user import CurrentUserService 16 | from app.domain.enums.user_role import UserRole 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | 21 | @dataclass(frozen=True, slots=True, kw_only=True) 22 | class ListUsersRequest: 23 | limit: int 24 | offset: int 25 | sorting_field: str | None 26 | sorting_order: SortingOrder | None 27 | 28 | 29 | class ListUsersResponse(TypedDict): 30 | users: list[UserQueryModel] 31 | 32 | 33 | class ListUsersQueryService: 34 | """ 35 | :raises AuthenticationError: 36 | :raises DataMapperError: 37 | :raises AuthorizationError: 38 | :raises PaginationError: 39 | :raises ReaderError: 40 | :raises SortingError: 41 | """ 42 | 43 | def __init__( 44 | self, 45 | current_user_service: CurrentUserService, 46 | authorization_service: AuthorizationService, 47 | user_query_gateway: UserQueryGateway, 48 | ): 49 | self._current_user_service = current_user_service 50 | self._authorization_service = authorization_service 51 | self._user_query_gateway = user_query_gateway 52 | 53 | async def __call__(self, request_data: ListUsersRequest) -> ListUsersResponse: 54 | log.info("List users by admin: started.") 55 | current_user = await self._current_user_service.get_current_user() 56 | self._authorization_service.authorize_for_subordinate_role( 57 | current_user, 58 | target_role=UserRole.USER, 59 | ) 60 | 61 | log.debug("Retrieving list of users.") 62 | user_read_all_params = UserReadAllParams( 63 | pagination=UserReadAllPagination( 64 | limit=request_data.limit, 65 | offset=request_data.offset, 66 | ), 67 | sorting=UserReadAllSorting( 68 | sorting_field=request_data.sorting_field or "username", 69 | sorting_order=request_data.sorting_order or SortingOrder.ASC, 70 | ), 71 | ) 72 | 73 | users: list[UserQueryModel] | None = await self._user_query_gateway.read_all( 74 | user_read_all_params, 75 | ) 76 | if users is None: 77 | log.error( 78 | "Retrieving list of users failed: invalid sorting column '%s'.", 79 | request_data.sorting_field, 80 | ) 81 | raise SortingError("Invalid sorting field.") 82 | 83 | response = ListUsersResponse(users=users) 84 | log.info("List users by admin: finished.") 85 | return response 86 | -------------------------------------------------------------------------------- /src/app/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/__init__.py -------------------------------------------------------------------------------- /src/app/domain/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/entities/__init__.py -------------------------------------------------------------------------------- /src/app/domain/entities/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from dataclasses import dataclass 3 | from typing import Any, TypeVar 4 | 5 | from app.domain.exceptions.base import DomainError 6 | from app.domain.value_objects.base import ValueObject 7 | 8 | T = TypeVar("T", bound=ValueObject) 9 | 10 | 11 | @dataclass(eq=False) 12 | class Entity[T: ValueObject](ABC): 13 | """ 14 | Base class for domain entities, defined by a unique identity (`id`). 15 | - `id`: Identity that remains constant throughout the entity's lifecycle. 16 | - Entities are mutable, but are compared solely by their `id`. 17 | - Subclasses must set `eq=False` to inherit the equality behavior. 18 | - Add `kw_only=True` in subclasses to enforce named arguments for clarity & safety. 19 | """ 20 | 21 | id_: T 22 | 23 | def __setattr__(self, name: str, value: Any) -> None: 24 | """ 25 | Prevents modifying the `id` after it's set. 26 | Other attributes can be changed as usual. 27 | """ 28 | if name == "id_" and getattr(self, "id_", None) is not None: 29 | raise DomainError("Changing entity id is not permitted.") 30 | super().__setattr__(name, value) 31 | 32 | def __eq__(self, other: Any) -> bool: 33 | """ 34 | Two entities are considered equal if they have the same `id`, 35 | regardless of other attribute values. 36 | """ 37 | return isinstance(other, type(self)) and other.id_ == self.id_ 38 | 39 | def __hash__(self) -> int: 40 | """ 41 | Generate a hash based on the immutable `id`. 42 | This allows entities to be used in hash-based collections. 43 | """ 44 | return hash(self.id_) 45 | -------------------------------------------------------------------------------- /src/app/domain/entities/user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from app.domain.entities.base import Entity 4 | from app.domain.enums.user_role import UserRole 5 | from app.domain.value_objects.user_id import UserId 6 | from app.domain.value_objects.user_password_hash import UserPasswordHash 7 | from app.domain.value_objects.username.username import Username 8 | 9 | 10 | @dataclass(eq=False, kw_only=True) 11 | class User(Entity[UserId]): 12 | username: Username 13 | password_hash: UserPasswordHash 14 | role: UserRole 15 | is_active: bool 16 | -------------------------------------------------------------------------------- /src/app/domain/enums/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/enums/__init__.py -------------------------------------------------------------------------------- /src/app/domain/enums/user_role.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class UserRole(StrEnum): 5 | SUPER_ADMIN = "super_admin" 6 | ADMIN = "admin" 7 | USER = "user" 8 | -------------------------------------------------------------------------------- /src/app/domain/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/exceptions/__init__.py -------------------------------------------------------------------------------- /src/app/domain/exceptions/base.py: -------------------------------------------------------------------------------- 1 | class DomainError(Exception): 2 | """ 3 | Exception for violations of fundamental domain rules, such as attempts to change 4 | the immutable `id` of an Entity, as well as for complex business rule violations. 5 | 6 | Not for: 7 | - Single attribute validation (use `DomainFieldError` instead). 8 | - Application, infrastructure, or system errors. 9 | """ 10 | 11 | 12 | class DomainFieldError(DomainError): 13 | """ 14 | Exception for validation errors in Value Objects and Entity field values. 15 | 16 | Use cases: 17 | 1. Violations of Value Object invariants during creation. 18 | 2. Single-field validation errors in Entities. 19 | 20 | Not for: 21 | - Complex business rule violations (use `DomainError` instead). 22 | - Input validation at application boundaries. 23 | """ 24 | -------------------------------------------------------------------------------- /src/app/domain/exceptions/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from app.domain.enums.user_role import UserRole 4 | from app.domain.exceptions.base import DomainError 5 | from app.domain.value_objects.username.username import Username 6 | 7 | 8 | class UsernameAlreadyExistsError(DomainError): 9 | def __init__(self, username: Any): 10 | message = f"User with username {username!r} already exists." 11 | super().__init__(message) 12 | 13 | 14 | class UserNotFoundByUsernameError(DomainError): 15 | def __init__(self, username: Username): 16 | message = f"User with username {username.value!r} is not found." 17 | super().__init__(message) 18 | 19 | 20 | class ActivationChangeNotPermittedError(DomainError): 21 | def __init__(self, username: Username, role: UserRole): 22 | message = ( 23 | f"Changing activation of user {username.value!r} ({role}) is not permitted." 24 | ) 25 | super().__init__(message) 26 | 27 | 28 | class RoleChangeNotPermittedError(DomainError): 29 | def __init__(self, username: Username, role: UserRole): 30 | message = f"Changing role of user {username.value!r} ({role}) is not permitted." 31 | super().__init__(message) 32 | -------------------------------------------------------------------------------- /src/app/domain/ports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/ports/__init__.py -------------------------------------------------------------------------------- /src/app/domain/ports/password_hasher.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from app.domain.value_objects.raw_password.raw_password import RawPassword 5 | 6 | 7 | class PasswordHasher(Protocol): 8 | @abstractmethod 9 | def hash(self, raw_password: RawPassword) -> bytes: ... 10 | 11 | @abstractmethod 12 | def verify(self, *, raw_password: RawPassword, hashed_password: bytes) -> bool: ... 13 | -------------------------------------------------------------------------------- /src/app/domain/ports/user_id_generator.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from uuid import UUID 3 | 4 | 5 | class UserIdGenerator: 6 | @abstractmethod 7 | def __call__(self) -> UUID: ... 8 | -------------------------------------------------------------------------------- /src/app/domain/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/services/__init__.py -------------------------------------------------------------------------------- /src/app/domain/services/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | - Flat (non-nested) models are best kept anemic (without methods). 3 | The behavior of such models is described in the domain service. 4 | 5 | - When working with non-flat models, such as aggregates, it makes sense 6 | to have rich models (with methods). The behavior of these models is 7 | described within the models themselves. 8 | """ 9 | 10 | from app.domain.entities.user import User 11 | from app.domain.enums.user_role import UserRole 12 | from app.domain.exceptions.user import ( 13 | ActivationChangeNotPermittedError, 14 | RoleChangeNotPermittedError, 15 | ) 16 | from app.domain.ports.password_hasher import PasswordHasher 17 | from app.domain.ports.user_id_generator import UserIdGenerator 18 | from app.domain.value_objects.raw_password.raw_password import RawPassword 19 | from app.domain.value_objects.user_id import UserId 20 | from app.domain.value_objects.user_password_hash import UserPasswordHash 21 | from app.domain.value_objects.username.username import Username 22 | 23 | 24 | class UserService: 25 | def __init__( 26 | self, 27 | user_id_generator: UserIdGenerator, 28 | password_hasher: PasswordHasher, 29 | ) -> None: 30 | self._user_id_generator = user_id_generator 31 | self._password_hasher = password_hasher 32 | 33 | def create_user(self, username: Username, raw_password: RawPassword) -> User: 34 | """ 35 | :raises DomainFieldError: 36 | """ 37 | user_id = UserId(self._user_id_generator()) 38 | password_hash = UserPasswordHash(self._password_hasher.hash(raw_password)) 39 | return User( 40 | id_=user_id, 41 | username=username, 42 | password_hash=password_hash, 43 | role=UserRole.USER, 44 | is_active=True, 45 | ) 46 | 47 | def is_password_valid(self, user: User, raw_password: RawPassword) -> bool: 48 | return self._password_hasher.verify( 49 | raw_password=raw_password, 50 | hashed_password=user.password_hash.value, 51 | ) 52 | 53 | def change_password(self, user: User, raw_password: RawPassword) -> None: 54 | hashed_password = UserPasswordHash(self._password_hasher.hash(raw_password)) 55 | user.password_hash = hashed_password 56 | 57 | def toggle_user_activation(self, user: User, *, is_active: bool) -> None: 58 | """ 59 | :raises ActivationChangeNotPermitted: 60 | """ 61 | if user.role == UserRole.SUPER_ADMIN: 62 | raise ActivationChangeNotPermittedError(user.username, user.role) 63 | user.is_active = is_active 64 | 65 | def toggle_user_admin_role(self, user: User, *, is_admin: bool) -> None: 66 | """ 67 | :raises RoleChangeNotPermitted: 68 | """ 69 | if user.role == UserRole.SUPER_ADMIN: 70 | raise RoleChangeNotPermittedError(user.username, user.role) 71 | user.role = UserRole.ADMIN if is_admin else UserRole.USER 72 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/value_objects/__init__.py -------------------------------------------------------------------------------- /src/app/domain/value_objects/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from dataclasses import asdict, dataclass, fields 3 | from typing import Any 4 | 5 | from app.domain.exceptions.base import DomainFieldError 6 | 7 | 8 | @dataclass(frozen=True, repr=False) 9 | class ValueObject(ABC): 10 | """ 11 | Base class for immutable value objects (VO) in the domain. 12 | - Defined by its attributes, which must also be immutable. 13 | - Subclasses should set `repr=False` to use the custom `__repr__` implementation 14 | from this class. 15 | 16 | For simple cases where immutability and additional behavior aren't required, 17 | consider using `NewType` from `typing` as a lightweight alternative 18 | to inheriting from this class. 19 | """ 20 | 21 | def __post_init__(self) -> None: 22 | """ 23 | Hook for additional initialization and ensuring invariants. 24 | 25 | Subclasses can override this method to implement custom logic, while 26 | still calling `super().__post_init__()` to preserve base checks. 27 | """ 28 | if not fields(self): 29 | raise DomainFieldError( 30 | f"{type(self).__name__} must have at least one field!", 31 | ) 32 | 33 | def __repr__(self) -> str: 34 | """ 35 | Returns a string representation of the value object. 36 | - With 1 field: outputs the value only. 37 | - With 2+ fields: outputs in `name=value` format. 38 | Subclasses must set `repr=False` in @dataclass for this to work. 39 | """ 40 | return f"{type(self).__name__}({self._repr_value()})" 41 | 42 | def _repr_value(self) -> str: 43 | """ 44 | Helper to build a string representation of the value object. 45 | - If there is one field, returns the value of that field. 46 | - Otherwise, returns a comma-separated list of `name=value` pairs. 47 | """ 48 | all_fields = fields(self) 49 | if len(all_fields) == 1: 50 | return f"{getattr(self, all_fields[0].name)!r}" 51 | return ", ".join(f"{f.name}={getattr(self, f.name)!r}" for f in all_fields) 52 | 53 | def get_fields(self) -> dict[str, Any]: 54 | """ 55 | Returns a dictionary of all attributes and their values. 56 | """ 57 | return asdict(self) 58 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/raw_password/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/value_objects/raw_password/__init__.py -------------------------------------------------------------------------------- /src/app/domain/value_objects/raw_password/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | PASSWORD_MIN_LEN: Final[int] = 6 4 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/raw_password/raw_password.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from app.domain.value_objects.base import ValueObject 4 | from app.domain.value_objects.raw_password.validation import validate_password_length 5 | 6 | 7 | @dataclass(frozen=True, repr=False) 8 | class RawPassword(ValueObject): 9 | """raises DomainFieldError""" 10 | 11 | value: str 12 | 13 | def __post_init__(self) -> None: 14 | """ 15 | :raises DomainFieldError: 16 | """ 17 | super().__post_init__() 18 | 19 | validate_password_length(self.value) 20 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/raw_password/validation.py: -------------------------------------------------------------------------------- 1 | from app.domain.exceptions.base import DomainFieldError 2 | from app.domain.value_objects.raw_password.constants import PASSWORD_MIN_LEN 3 | 4 | 5 | def validate_password_length(password_value: str) -> None: 6 | if len(password_value) < PASSWORD_MIN_LEN: 7 | raise DomainFieldError( 8 | f"Password must be at least {PASSWORD_MIN_LEN} characters long.", 9 | ) 10 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/user_id.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from uuid import UUID 3 | 4 | from app.domain.value_objects.base import ValueObject 5 | 6 | 7 | @dataclass(frozen=True, repr=False) 8 | class UserId(ValueObject): 9 | value: UUID 10 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/user_password_hash.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from app.domain.value_objects.base import ValueObject 4 | 5 | 6 | @dataclass(frozen=True, repr=False) 7 | class UserPasswordHash(ValueObject): 8 | value: bytes 9 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/username/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/domain/value_objects/username/__init__.py -------------------------------------------------------------------------------- /src/app/domain/value_objects/username/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Final 3 | 4 | USERNAME_MIN_LEN: Final[int] = 5 5 | USERNAME_MAX_LEN: Final[int] = 20 6 | 7 | # Pattern for validating a username: 8 | # - starts with a letter (A-Z, a-z) or a digit (0-9) 9 | PATTERN_START: Final[re.Pattern[str]] = re.compile( 10 | r"^[a-zA-Z0-9]", 11 | ) 12 | # - can contain multiple special characters . - _ between letters and digits, 13 | PATTERN_ALLOWED_CHARS: Final[re.Pattern[str]] = re.compile( 14 | r"[a-zA-Z0-9._-]*", 15 | ) 16 | # but only one special character can appear consecutively 17 | PATTERN_NO_CONSECUTIVE_SPECIALS: Final[re.Pattern[str]] = re.compile( 18 | r"^[a-zA-Z0-9]+([._-]?[a-zA-Z0-9]+)*[._-]?$", 19 | ) 20 | # - ends with a letter (A-Z, a-z) or a digit (0-9) 21 | PATTERN_END: Final[re.Pattern[str]] = re.compile( 22 | r".*[a-zA-Z0-9]$", 23 | ) 24 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/username/username.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from app.domain.value_objects.base import ValueObject 4 | from app.domain.value_objects.username.validation import ( 5 | validate_username_length, 6 | validate_username_pattern, 7 | ) 8 | 9 | 10 | @dataclass(frozen=True, repr=False) 11 | class Username(ValueObject): 12 | """raises DomainFieldError""" 13 | 14 | value: str 15 | 16 | def __post_init__(self) -> None: 17 | """ 18 | :raises DomainFieldError: 19 | """ 20 | super().__post_init__() 21 | 22 | validate_username_length(self.value) 23 | validate_username_pattern(self.value) 24 | -------------------------------------------------------------------------------- /src/app/domain/value_objects/username/validation.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from app.domain.exceptions.base import DomainFieldError 4 | from app.domain.value_objects.username.constants import ( 5 | PATTERN_ALLOWED_CHARS, 6 | PATTERN_END, 7 | PATTERN_NO_CONSECUTIVE_SPECIALS, 8 | PATTERN_START, 9 | USERNAME_MAX_LEN, 10 | USERNAME_MIN_LEN, 11 | ) 12 | 13 | 14 | def validate_username_length(username_value: str) -> None: 15 | if len(username_value) < USERNAME_MIN_LEN or len(username_value) > USERNAME_MAX_LEN: 16 | raise DomainFieldError( 17 | f"Username must be between " 18 | f"{USERNAME_MIN_LEN} and " 19 | f"{USERNAME_MAX_LEN} characters.", 20 | ) 21 | 22 | 23 | def validate_username_pattern(username_value: str) -> None: 24 | """ 25 | :raises DomainFieldError: 26 | """ 27 | if not re.match(PATTERN_START, username_value): 28 | raise DomainFieldError( 29 | "Username must start with a letter (A-Z, a-z) or a digit (0-9).", 30 | ) 31 | if not re.fullmatch(PATTERN_ALLOWED_CHARS, username_value): 32 | raise DomainFieldError( 33 | "Username can only contain letters (A-Z, a-z), digits (0-9), " 34 | "dots (.), hyphens (-), and underscores (_).", 35 | ) 36 | if not re.fullmatch(PATTERN_NO_CONSECUTIVE_SPECIALS, username_value): 37 | raise DomainFieldError( 38 | "Username cannot contain consecutive special characters" 39 | " like .., --, or __.", 40 | ) 41 | if not re.match(PATTERN_END, username_value): 42 | raise DomainFieldError( 43 | "Username must end with a letter (A-Z, a-z) or a digit (0-9).", 44 | ) 45 | -------------------------------------------------------------------------------- /src/app/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/adapters/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/adapters/application/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/application/new_types.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | # database 6 | UserAsyncSession = NewType("UserAsyncSession", AsyncSession) 7 | -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/application/sqla_user_data_mapper.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Select, select 2 | from sqlalchemy.exc import SQLAlchemyError 3 | 4 | from app.application.common.ports.command_gateways.user import UserCommandGateway 5 | from app.domain.entities.user import User 6 | from app.domain.value_objects.user_id import UserId 7 | from app.domain.value_objects.username.username import Username 8 | from app.infrastructure.adapters.application.new_types import UserAsyncSession 9 | from app.infrastructure.exceptions.gateway_implementations import DataMapperError 10 | 11 | 12 | class SqlaUserDataMapper(UserCommandGateway): 13 | def __init__(self, session: UserAsyncSession): 14 | self._session = session 15 | 16 | def add(self, user: User) -> None: 17 | """ 18 | :raises DataMapperError: 19 | """ 20 | try: 21 | self._session.add(user) 22 | 23 | except SQLAlchemyError as error: 24 | raise DataMapperError("Database query failed.") from error 25 | 26 | async def read_by_id(self, user_id: UserId) -> User | None: 27 | """ 28 | :raises DataMapperError: 29 | """ 30 | select_stmt: Select[tuple[User]] = select(User).where(User.id_ == user_id) # type: ignore 31 | 32 | try: 33 | user: User | None = ( 34 | await self._session.execute(select_stmt) 35 | ).scalar_one_or_none() 36 | 37 | return user 38 | 39 | except SQLAlchemyError as error: 40 | raise DataMapperError("Database query failed.") from error 41 | 42 | async def read_by_username( 43 | self, 44 | username: Username, 45 | for_update: bool = False, 46 | ) -> User | None: 47 | """ 48 | :raises DataMapperError: 49 | """ 50 | select_stmt: Select[tuple[User]] = select(User).where(User.username == username) # type: ignore 51 | 52 | if for_update: 53 | select_stmt = select_stmt.with_for_update() 54 | 55 | try: 56 | user: User | None = ( 57 | await self._session.execute(select_stmt) 58 | ).scalar_one_or_none() 59 | 60 | return user 61 | 62 | except SQLAlchemyError as error: 63 | raise DataMapperError("Database query failed.") from error 64 | -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/application/sqla_user_reader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Sequence 3 | from uuid import UUID 4 | 5 | from sqlalchemy import ColumnElement, Result, Row, Select, select 6 | from sqlalchemy.exc import SQLAlchemyError 7 | 8 | from app.application.common.ports.query_gateways.user import UserQueryGateway 9 | from app.application.common.query_filters.sorting_order_enum import SortingOrder 10 | from app.application.common.query_filters.user.read_all import UserReadAllParams 11 | from app.application.common.query_models.user import UserQueryModel 12 | from app.domain.enums.user_role import UserRole 13 | from app.infrastructure.adapters.application.new_types import UserAsyncSession 14 | from app.infrastructure.exceptions.gateway_implementations import ReaderError 15 | from app.infrastructure.sqla_persistence.mappings.user import users_table 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | class SqlaUserReader(UserQueryGateway): 21 | def __init__(self, session: UserAsyncSession): 22 | self._session = session 23 | 24 | async def read_all( 25 | self, 26 | user_read_all_params: UserReadAllParams, 27 | ) -> list[UserQueryModel] | None: 28 | """ 29 | :raises ReaderError: 30 | """ 31 | table_sorting_field: ColumnElement[UUID | str | UserRole | bool] | None = ( 32 | users_table.c.get(user_read_all_params.sorting.sorting_field) 33 | ) 34 | if table_sorting_field is None: 35 | log.error( 36 | "Invalid sorting field: '%s'.", 37 | user_read_all_params.sorting.sorting_field, 38 | ) 39 | return None 40 | 41 | order_by: ColumnElement[UUID | str | UserRole | bool] = ( 42 | table_sorting_field.asc() 43 | if user_read_all_params.sorting.sorting_order == SortingOrder.ASC 44 | else table_sorting_field.desc() 45 | ) 46 | 47 | select_stmt: Select[tuple[UUID, str, UserRole, bool]] = ( 48 | select( 49 | users_table.c.id, 50 | users_table.c.username, 51 | users_table.c.role, 52 | users_table.c.is_active, 53 | ) 54 | .order_by(order_by) 55 | .limit(user_read_all_params.pagination.limit) 56 | .offset(user_read_all_params.pagination.offset) 57 | ) 58 | 59 | try: 60 | result: Result[ 61 | tuple[UUID, str, UserRole, bool] 62 | ] = await self._session.execute(select_stmt) 63 | rows: Sequence[Row[tuple[UUID, str, UserRole, bool]]] = result.all() 64 | 65 | return [ 66 | UserQueryModel( 67 | id_=row.id, 68 | username=row.username, 69 | role=row.role, 70 | is_active=row.is_active, 71 | ) 72 | for row in rows 73 | ] 74 | 75 | except SQLAlchemyError as error: 76 | raise ReaderError("Database query failed.") from error 77 | -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/application/sqla_user_transaction_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Mapping 3 | from typing import Any, cast 4 | 5 | from sqlalchemy.exc import IntegrityError, SQLAlchemyError 6 | 7 | from app.application.common.ports.transaction_manager import TransactionManager 8 | from app.domain.exceptions.user import UsernameAlreadyExistsError 9 | from app.infrastructure.adapters.application.new_types import UserAsyncSession 10 | from app.infrastructure.exceptions.gateway_implementations import DataMapperError 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class SqlaUserTransactionManager(TransactionManager): 16 | def __init__(self, session: UserAsyncSession): 17 | self._session = session 18 | 19 | async def flush(self) -> None: 20 | """ 21 | :raises DataMapperError: 22 | :raises UsernameAlreadyExists: 23 | """ 24 | try: 25 | await self._session.flush() 26 | log.debug("Flush was done by User session.") 27 | 28 | except IntegrityError as error: 29 | if "uq_users_username" in str(error): 30 | params: Mapping[str, Any] = cast(Mapping[str, Any], error.params) 31 | username = str(params.get("username", "unknown")) 32 | raise UsernameAlreadyExistsError(username) from error 33 | 34 | raise DataMapperError("Database constraint violation.") from error 35 | 36 | except SQLAlchemyError as error: 37 | raise DataMapperError("Database query failed, flush failed.") from error 38 | 39 | async def commit(self) -> None: 40 | """ 41 | :raises DataMapperError: 42 | """ 43 | try: 44 | await self._session.commit() 45 | log.debug("Commit was done by User session.") 46 | 47 | except SQLAlchemyError as error: 48 | raise DataMapperError("Database query failed, commit failed.") from error 49 | -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/adapters/domain/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/domain/bcrypt_password_hasher.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | 5 | import bcrypt 6 | 7 | from app.domain.ports.password_hasher import PasswordHasher 8 | from app.domain.value_objects.raw_password.raw_password import RawPassword 9 | from app.infrastructure.adapters.domain.new_types import PasswordPepper 10 | 11 | 12 | class BcryptPasswordHasher(PasswordHasher): 13 | def __init__(self, pepper: PasswordPepper): 14 | self._pepper = pepper 15 | 16 | def hash(self, raw_password: RawPassword) -> bytes: 17 | """ 18 | Bcrypt is limited to 72-character passwords. Adding a pepper may surpass this character count. 19 | To keep the input within the 72-character limit, pre-hashing can be employed. 20 | One option is using HMAC-SHA256, which produces a fixed-length digest of the peppered password. 21 | However, pre-hashing may introduce null bytes, which `bcrypt` cannot process correctly. 22 | This issue can be resolved by applying `base64` encoding to the digest. 23 | The resulting `base64(hmac-sha256(password, pepper))` string is then ready for bcrypt hashing. 24 | Salt is added to this string before passing it to `bcrypt` for the final hashing step. 25 | """ 26 | base64_hmac_password: bytes = self._add_pepper(raw_password, self._pepper) 27 | salt: bytes = bcrypt.gensalt() 28 | return bcrypt.hashpw(base64_hmac_password, salt) 29 | 30 | @staticmethod 31 | def _add_pepper(raw_password: RawPassword, pepper: PasswordPepper) -> bytes: 32 | hmac_password: bytes = hmac.new( 33 | key=pepper.encode(), 34 | msg=raw_password.value.encode(), 35 | digestmod=hashlib.sha256, 36 | ).digest() 37 | return base64.b64encode(hmac_password) 38 | 39 | def verify(self, *, raw_password: RawPassword, hashed_password: bytes) -> bool: 40 | base64_hmac_password: bytes = self._add_pepper(raw_password, self._pepper) 41 | return bcrypt.checkpw(base64_hmac_password, hashed_password) 42 | -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/domain/new_types.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | 3 | # security.password 4 | PasswordPepper = NewType("PasswordPepper", str) 5 | -------------------------------------------------------------------------------- /src/app/infrastructure/adapters/domain/uuid_user_id_generator.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import uuid6 4 | 5 | from app.domain.ports.user_id_generator import UserIdGenerator 6 | 7 | 8 | class UuidUserIdGenerator(UserIdGenerator): 9 | def __call__(self) -> UUID: 10 | return uuid6.uuid7() 11 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/auth_context/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/auth_context/common/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/application_adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/auth_context/common/application_adapters/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/application_adapters/auth_session_access_revoker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.application.common.ports.access_revoker import AccessRevoker 4 | from app.domain.value_objects.user_id import UserId 5 | from app.infrastructure.auth_context.common.sqla_auth_session_data_mapper import ( 6 | SqlaAuthSessionDataMapper, 7 | ) 8 | from app.infrastructure.auth_context.common.sqla_auth_transaction_manager import ( 9 | SqlaAuthTransactionManager, 10 | ) 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class AuthSessionAccessRevoker(AccessRevoker): 16 | def __init__( 17 | self, 18 | sqla_auth_session_data_mapper: SqlaAuthSessionDataMapper, 19 | sqla_auth_transaction_manager: SqlaAuthTransactionManager, 20 | ): 21 | self._sqla_auth_session_data_mapper = sqla_auth_session_data_mapper 22 | self._sqla_auth_transaction_manager = sqla_auth_transaction_manager 23 | 24 | async def remove_all_user_access(self, user_id: UserId) -> None: 25 | """ 26 | :raises DataMapperError: 27 | """ 28 | log.debug("Remove all user access: started. User id: '%s'.", user_id.value) 29 | 30 | await self._sqla_auth_session_data_mapper.delete_all_for_user(user_id) 31 | 32 | await self._sqla_auth_transaction_manager.commit() 33 | log.debug("Remove all user access: done. User id: '%s'.", user_id.value) 34 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/application_adapters/auth_session_identity_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.application.common.ports.identity_provider import IdentityProvider 4 | from app.domain.value_objects.user_id import UserId 5 | from app.infrastructure.auth_context.common.auth_exceptions import AuthenticationError 6 | from app.infrastructure.auth_context.common.auth_session import AuthSession 7 | from app.infrastructure.auth_context.common.managers.auth_session import ( 8 | AuthSessionManager, 9 | ) 10 | from app.infrastructure.auth_context.common.managers.jwt_token import JwtTokenManager 11 | from app.infrastructure.auth_context.common.sqla_auth_transaction_manager import ( 12 | SqlaAuthTransactionManager, 13 | ) 14 | from app.infrastructure.exceptions.gateway_implementations import DataMapperError 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | class AuthSessionIdentityProvider(IdentityProvider): 20 | def __init__( 21 | self, 22 | jwt_token_manager: JwtTokenManager, 23 | auth_session_manager: AuthSessionManager, 24 | sqla_auth_transaction_manager: SqlaAuthTransactionManager, 25 | ): 26 | self._jwt_token_manager = jwt_token_manager 27 | self._auth_session_manager = auth_session_manager 28 | self._sqla_auth_transaction_manager = sqla_auth_transaction_manager 29 | 30 | async def get_current_user_id(self) -> UserId: 31 | """ 32 | :raises AuthenticationError: 33 | """ 34 | log.debug("Get current user id: started.") 35 | 36 | access_token: str | None = ( 37 | self._jwt_token_manager.get_access_token_from_request() 38 | ) 39 | if access_token is None: 40 | raise AuthenticationError("Not authenticated.") 41 | 42 | auth_session_id: str | None = ( 43 | self._jwt_token_manager.get_auth_session_id_from_access_token(access_token) 44 | ) 45 | if auth_session_id is None: 46 | raise AuthenticationError("Not authenticated.") 47 | 48 | auth_session: ( 49 | AuthSession | None 50 | ) = await self._auth_session_manager.get_auth_session( 51 | auth_session_id, 52 | for_update=True, 53 | ) 54 | if auth_session is None: 55 | raise AuthenticationError("Not authenticated.") 56 | 57 | if self._auth_session_manager.is_auth_session_expired(auth_session): 58 | raise AuthenticationError("Not authenticated.") 59 | 60 | if self._auth_session_manager.is_auth_session_near_expiry(auth_session): 61 | self._auth_session_manager.prolong_auth_session(auth_session) 62 | 63 | new_access_token: str = self._jwt_token_manager.issue_access_token( 64 | auth_session.id_, 65 | ) 66 | self._jwt_token_manager.add_access_token_to_request(new_access_token) 67 | 68 | try: 69 | await self._sqla_auth_transaction_manager.commit() 70 | 71 | except DataMapperError as error: 72 | log.error("Auto prolongation of auth session failed: '%s'", error) 73 | 74 | log.debug("Get current user id: done.") 75 | return auth_session.user_id 76 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/auth_exceptions.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.exceptions.base import InfrastructureError 2 | 3 | 4 | class AuthenticationError(InfrastructureError): 5 | pass 6 | 7 | 8 | class AlreadyAuthenticatedError(InfrastructureError): 9 | pass 10 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/auth_session.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from app.domain.value_objects.user_id import UserId 5 | 6 | 7 | @dataclass(eq=False, kw_only=True) 8 | class AuthSession: 9 | """ 10 | This class can become a domain entity in a new bounded context, enabling 11 | a monolithic architecture to become modular, while the other classes working 12 | with it are likely to become application and infrastructure layer components. 13 | 14 | For example, `SignUpHandler` can become an interactor. 15 | """ 16 | 17 | id_: str 18 | user_id: UserId 19 | expiration: datetime 20 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/jwt_access_token_processor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, cast 3 | 4 | import jwt 5 | 6 | from app.infrastructure.auth_context.common.new_types import JwtAlgorithm, JwtSecret 7 | from app.infrastructure.auth_context.common.utc_auth_session_timer import ( 8 | UtcAuthSessionTimer, 9 | ) 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class JwtAccessTokenProcessor: 15 | def __init__( 16 | self, 17 | secret: JwtSecret, 18 | algorithm: JwtAlgorithm, 19 | utc_auth_session_timer: UtcAuthSessionTimer, 20 | ): 21 | self._secret = secret 22 | self._algorithm = algorithm 23 | self._utc_auth_session_timer = utc_auth_session_timer 24 | 25 | def issue_access_token(self, auth_session_id: str) -> str: 26 | to_encode: dict[str, Any] = { 27 | "auth_session_id": auth_session_id, 28 | "exp": int(self._utc_auth_session_timer.access_expiration.timestamp()), 29 | } 30 | 31 | return jwt.encode( 32 | payload=to_encode, 33 | key=self._secret, 34 | algorithm=self._algorithm, 35 | ) 36 | 37 | def extract_auth_session_id(self, access_token: str) -> str | None: 38 | payload: dict[str, Any] | None = self._decode_token(access_token) 39 | if payload is None: 40 | log.debug("Empty payload in token.") 41 | return None 42 | 43 | auth_session_id: str | None = payload.get("auth_session_id") 44 | if auth_session_id is None: 45 | log.debug("Token has no auth session id.") 46 | return None 47 | 48 | return auth_session_id 49 | 50 | def _decode_token(self, token: str) -> dict[str, Any] | None: 51 | try: 52 | return cast( 53 | dict[str, Any], 54 | jwt.decode( 55 | jwt=token, 56 | key=self._secret, 57 | algorithms=[self._algorithm], 58 | ), 59 | ) 60 | 61 | except jwt.PyJWTError as error: 62 | log.debug("Token is invalid or expired. Error: %s", error) 63 | return None 64 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/managers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/auth_context/common/managers/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/managers/jwt_token.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.infrastructure.auth_context.common.jwt_access_token_processor import ( 4 | JwtAccessTokenProcessor, 5 | ) 6 | from app.infrastructure.auth_context.common.ports.access_token_request_handler import ( 7 | AccessTokenRequestHandler, 8 | ) 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class JwtTokenManager: 14 | def __init__( 15 | self, 16 | jwt_access_token_processor: JwtAccessTokenProcessor, 17 | access_token_request_handler: AccessTokenRequestHandler, 18 | ): 19 | self._jwt_access_token_processor = jwt_access_token_processor 20 | self._access_token_request_handler = access_token_request_handler 21 | 22 | def issue_access_token(self, auth_session_id: str) -> str: 23 | log.debug( 24 | "Issue access token: started. Auth session id: '%s'.", 25 | auth_session_id, 26 | ) 27 | 28 | access_token: str = self._jwt_access_token_processor.issue_access_token( 29 | auth_session_id, 30 | ) 31 | 32 | log.debug("Issue access token: done. Auth session id: '%s'.", auth_session_id) 33 | return access_token 34 | 35 | def add_access_token_to_request(self, access_token: str) -> None: 36 | log.debug("Add access token to request: started.") 37 | 38 | self._access_token_request_handler.add_access_token_to_request(access_token) 39 | 40 | log.debug("Add access token to request: done.") 41 | 42 | def delete_access_token_from_request(self) -> None: 43 | log.debug("Delete access token from request: started.") 44 | 45 | self._access_token_request_handler.delete_access_token_from_request() 46 | 47 | log.debug("Delete access token from request: done.") 48 | 49 | def get_access_token_from_request(self) -> str | None: 50 | log.debug("Get access token from request: started.") 51 | 52 | access_token: str | None = ( 53 | self._access_token_request_handler.get_access_token_from_request() 54 | ) 55 | if not access_token: 56 | log.debug( 57 | "Get access token from request: done. No access token in request.", 58 | ) 59 | return None 60 | 61 | log.debug("Get access token from request: done.") 62 | return access_token 63 | 64 | def get_auth_session_id_from_access_token(self, access_token: str) -> str | None: 65 | log.debug("Get auth session id from access token: started.") 66 | 67 | auth_session_id: str | None = ( 68 | self._jwt_access_token_processor.extract_auth_session_id(access_token) 69 | ) 70 | if auth_session_id is None: 71 | log.debug( 72 | "Get auth session id from access token: failed. No auth session id.", 73 | ) 74 | return None 75 | 76 | log.debug("Get auth session id from access token: done.") 77 | return auth_session_id 78 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/new_types.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Literal, NewType 3 | 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | # security.auth 7 | JwtSecret = NewType("JwtSecret", str) 8 | JwtAlgorithm = Literal["HS256", "HS384", "HS512", "RS256", "RS384", "RS512"] 9 | AuthSessionTtlMin = NewType("AuthSessionTtlMin", timedelta) 10 | AuthSessionRefreshThreshold = NewType("AuthSessionRefreshThreshold", float) 11 | 12 | # database 13 | AuthAsyncSession = NewType("AuthAsyncSession", AsyncSession) 14 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/ports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/auth_context/common/ports/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/ports/access_token_request_handler.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | 5 | class AccessTokenRequestHandler(Protocol): 6 | @abstractmethod 7 | def get_access_token_from_request(self) -> str | None: ... 8 | 9 | @abstractmethod 10 | def add_access_token_to_request(self, new_access_token: str) -> None: ... 11 | 12 | @abstractmethod 13 | def delete_access_token_from_request(self) -> None: ... 14 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/sqla_auth_session_data_mapper.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Delete, delete 2 | from sqlalchemy.exc import SQLAlchemyError 3 | from sqlalchemy.sql.dml import ReturningDelete 4 | 5 | from app.domain.value_objects.user_id import UserId 6 | from app.infrastructure.auth_context.common.auth_session import AuthSession 7 | from app.infrastructure.auth_context.common.new_types import AuthAsyncSession 8 | from app.infrastructure.exceptions.gateway_implementations import DataMapperError 9 | 10 | 11 | class SqlaAuthSessionDataMapper: 12 | def __init__(self, session: AuthAsyncSession): 13 | self._session = session 14 | 15 | def add(self, auth_session: AuthSession) -> None: 16 | """ 17 | :raises DataMapperError: 18 | """ 19 | try: 20 | self._session.add(auth_session) 21 | 22 | except SQLAlchemyError as error: 23 | raise DataMapperError("Database query failed.") from error 24 | 25 | async def read( 26 | self, 27 | auth_session_id: str, 28 | for_update: bool = False, 29 | ) -> AuthSession | None: 30 | """ 31 | :raises DataMapperError: 32 | """ 33 | try: 34 | auth_session: AuthSession | None = await self._session.get( 35 | AuthSession, 36 | auth_session_id, 37 | with_for_update=for_update, 38 | ) 39 | 40 | return auth_session 41 | 42 | except SQLAlchemyError as error: 43 | raise DataMapperError("Database query failed.") from error 44 | 45 | async def delete(self, auth_session_id: str) -> bool: 46 | """ 47 | :raises DataMapperError: 48 | """ 49 | delete_stmt: ReturningDelete[tuple[str, ...]] = ( 50 | delete(AuthSession) 51 | .where(AuthSession.id_ == auth_session_id) # type: ignore 52 | .returning(AuthSession.id_) 53 | ) 54 | 55 | try: 56 | result = await self._session.execute(delete_stmt) 57 | 58 | deleted_ids: tuple[str, ...] = tuple(result.scalars().all()) 59 | 60 | return bool(deleted_ids) 61 | 62 | except SQLAlchemyError as error: 63 | raise DataMapperError("Database query failed.") from error 64 | 65 | async def delete_all_for_user(self, user_id: UserId) -> None: 66 | """ 67 | :raises DataMapperError: 68 | """ 69 | delete_stmt: Delete = delete(AuthSession).where( 70 | (AuthSession.user_id == user_id), # type: ignore 71 | ) 72 | 73 | try: 74 | await self._session.execute(delete_stmt) 75 | 76 | except SQLAlchemyError as error: 77 | raise DataMapperError("Database query failed.") from error 78 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/sqla_auth_transaction_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy.exc import IntegrityError, SQLAlchemyError 4 | 5 | from app.application.common.ports.transaction_manager import TransactionManager 6 | from app.infrastructure.auth_context.common.new_types import AuthAsyncSession 7 | from app.infrastructure.exceptions.gateway_implementations import DataMapperError 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class SqlaAuthTransactionManager(TransactionManager): 13 | def __init__(self, session: AuthAsyncSession): 14 | self._session = session 15 | 16 | async def flush(self) -> None: 17 | """ 18 | :raises DataMapperError: 19 | """ 20 | try: 21 | await self._session.flush() 22 | log.debug("Flush was done by Auth session.") 23 | 24 | except IntegrityError as error: 25 | raise DataMapperError("Database constraint violation.") from error 26 | 27 | except SQLAlchemyError as error: 28 | raise DataMapperError("Database query failed, flush failed.") from error 29 | 30 | async def commit(self) -> None: 31 | """ 32 | :raises DataMapperError: 33 | """ 34 | try: 35 | await self._session.commit() 36 | log.debug("Commit was done by Auth session.") 37 | 38 | except SQLAlchemyError as error: 39 | raise DataMapperError("Database query failed, commit failed.") from error 40 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/str_auth_session_id_generator.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | 4 | class StrAuthSessionIdGenerator: 5 | def __call__(self) -> str: 6 | return secrets.token_urlsafe(32) 7 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/common/utc_auth_session_timer.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime, timedelta 2 | 3 | from app.infrastructure.auth_context.common.new_types import ( 4 | AuthSessionRefreshThreshold, 5 | AuthSessionTtlMin, 6 | ) 7 | 8 | 9 | class UtcAuthSessionTimer: 10 | def __init__( 11 | self, 12 | auth_session_ttl_min: AuthSessionTtlMin, 13 | auth_session_refresh_threshold: AuthSessionRefreshThreshold, 14 | ): 15 | self._auth_session_ttl_min = auth_session_ttl_min 16 | self._auth_session_refresh_threshold = auth_session_refresh_threshold 17 | 18 | @property 19 | def current_time(self) -> datetime: 20 | return datetime.now(tz=UTC) 21 | 22 | @property 23 | def access_expiration(self) -> datetime: 24 | return self.current_time + self._auth_session_ttl_min 25 | 26 | @property 27 | def refresh_trigger_interval(self) -> timedelta: 28 | return self._auth_session_ttl_min * self._auth_session_refresh_threshold 29 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/log_out.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.domain.entities.user import User 4 | from app.domain.value_objects.user_id import UserId 5 | from app.infrastructure.adapters.application.sqla_user_data_mapper import ( 6 | SqlaUserDataMapper, 7 | ) 8 | from app.infrastructure.auth_context.common.application_adapters.auth_session_identity_provider import ( 9 | AuthSessionIdentityProvider, 10 | ) 11 | from app.infrastructure.auth_context.common.auth_exceptions import AuthenticationError 12 | from app.infrastructure.auth_context.common.auth_session import AuthSession 13 | from app.infrastructure.auth_context.common.managers.auth_session import ( 14 | AuthSessionManager, 15 | ) 16 | from app.infrastructure.auth_context.common.managers.jwt_token import JwtTokenManager 17 | from app.infrastructure.auth_context.common.sqla_auth_transaction_manager import ( 18 | SqlaAuthTransactionManager, 19 | ) 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | 24 | class LogOutHandler: 25 | """ 26 | :raises AuthenticationError: 27 | :raises DataMapperError: 28 | """ 29 | 30 | def __init__( 31 | self, 32 | auth_session_identity_provider: AuthSessionIdentityProvider, 33 | sqla_user_data_mapper: SqlaUserDataMapper, 34 | auth_session_manager: AuthSessionManager, 35 | jwt_token_manager: JwtTokenManager, 36 | sqla_auth_transaction_manager: SqlaAuthTransactionManager, 37 | ): 38 | self._auth_session_identity_provider = auth_session_identity_provider 39 | self._sqla_user_data_mapper = sqla_user_data_mapper 40 | self._auth_session_manager = auth_session_manager 41 | self._jwt_token_manager = jwt_token_manager 42 | self._sqla_auth_transaction_manager = sqla_auth_transaction_manager 43 | 44 | async def __call__(self) -> None: 45 | log.info("Log out: started for unknown user.") 46 | 47 | user_id: UserId = ( 48 | await self._auth_session_identity_provider.get_current_user_id() 49 | ) 50 | 51 | user: User | None = await self._sqla_user_data_mapper.read_by_id(user_id) 52 | if user is None: 53 | raise AuthenticationError("Not authenticated.") 54 | 55 | log.info("Log out: user identified. Username: '%s'.", user.username.value) 56 | 57 | current_auth_session: ( 58 | AuthSession | None 59 | ) = await self._auth_session_manager.get_current_auth_session() 60 | if current_auth_session is None: 61 | raise AuthenticationError("Not authenticated.") 62 | 63 | self._jwt_token_manager.delete_access_token_from_request() 64 | log.debug( 65 | "Access token deleted. Auth session id: '%s'.", 66 | current_auth_session.id_, 67 | ) 68 | 69 | if not await self._auth_session_manager.delete_auth_session( 70 | current_auth_session.id_, 71 | ): 72 | log.warning( 73 | ( 74 | "Log out failed: partially completed. " 75 | "Access token was deleted, but auth session was not. " 76 | "Username: '%s'. Auth session ID: '%s'." 77 | ), 78 | user.username.value, 79 | current_auth_session.id_, 80 | ) 81 | 82 | await self._sqla_auth_transaction_manager.commit() 83 | 84 | log.info("Log out: done. Username: '%s'.", user.username.value) 85 | -------------------------------------------------------------------------------- /src/app/infrastructure/auth_context/sign_up.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from typing import TypedDict 4 | from uuid import UUID 5 | 6 | from app.domain.entities.user import User 7 | from app.domain.exceptions.user import UsernameAlreadyExistsError 8 | from app.domain.services.user import UserService 9 | from app.domain.value_objects.raw_password.raw_password import RawPassword 10 | from app.domain.value_objects.username.username import Username 11 | from app.infrastructure.adapters.application.sqla_user_data_mapper import ( 12 | SqlaUserDataMapper, 13 | ) 14 | from app.infrastructure.adapters.application.sqla_user_transaction_manager import ( 15 | SqlaUserTransactionManager, 16 | ) 17 | from app.infrastructure.auth_context.common.application_adapters.auth_session_identity_provider import ( 18 | AuthSessionIdentityProvider, 19 | ) 20 | from app.infrastructure.auth_context.common.auth_exceptions import ( 21 | AlreadyAuthenticatedError, 22 | AuthenticationError, 23 | ) 24 | 25 | log = logging.getLogger(__name__) 26 | 27 | 28 | @dataclass(frozen=True, slots=True, kw_only=True) 29 | class SignUpRequest: 30 | username: str 31 | password: str 32 | 33 | 34 | class SignUpResponse(TypedDict): 35 | id: UUID 36 | 37 | 38 | class SignUpHandler: 39 | """ 40 | :raises AlreadyAuthenticatedError: 41 | :raises DomainFieldError: 42 | :raises DataMapperError: 43 | :raises UsernameAlreadyExists: 44 | """ 45 | 46 | def __init__( 47 | self, 48 | auth_session_identity_provider: AuthSessionIdentityProvider, 49 | sqla_user_data_mapper: SqlaUserDataMapper, 50 | user_service: UserService, 51 | sqla_user_transaction_manager: SqlaUserTransactionManager, 52 | ): 53 | self._auth_session_identity_provider = auth_session_identity_provider 54 | self._sqla_user_data_mapper = sqla_user_data_mapper 55 | self._user_service = user_service 56 | self._sqla_user_transaction_manager = sqla_user_transaction_manager 57 | 58 | async def __call__(self, request_data: SignUpRequest) -> SignUpResponse: 59 | log.info("Sign up: started. Username: '%s'.", request_data.username) 60 | 61 | try: 62 | await self._auth_session_identity_provider.get_current_user_id() 63 | raise AlreadyAuthenticatedError( 64 | "You are already authenticated. Consider logging out.", 65 | ) 66 | except AuthenticationError: 67 | pass 68 | 69 | username = Username(request_data.username) 70 | password = RawPassword(request_data.password) 71 | 72 | user: User = self._user_service.create_user(username, password) 73 | 74 | self._sqla_user_data_mapper.add(user) 75 | 76 | try: 77 | await self._sqla_user_transaction_manager.flush() 78 | except UsernameAlreadyExistsError: 79 | raise 80 | 81 | await self._sqla_user_transaction_manager.commit() 82 | 83 | log.info("Sign up: done. Username: '%s'.", user.username.value) 84 | return SignUpResponse(id=user.id_.value) 85 | -------------------------------------------------------------------------------- /src/app/infrastructure/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/exceptions/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/exceptions/base.py: -------------------------------------------------------------------------------- 1 | class InfrastructureError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/app/infrastructure/exceptions/gateway_implementations.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.exceptions.base import InfrastructureError 2 | 3 | 4 | class DataMapperError(InfrastructureError): 5 | pass 6 | 7 | 8 | class ReaderError(InfrastructureError): 9 | pass 10 | -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/sqla_persistence/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/alembic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/sqla_persistence/alembic/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/alembic/env.py: -------------------------------------------------------------------------------- 1 | __all__ = ("alembic_postgresql_enum",) 2 | 3 | import asyncio 4 | from logging.config import fileConfig 5 | 6 | import alembic_postgresql_enum # this is needed for enum management 7 | from alembic import context 8 | from sqlalchemy import pool 9 | from sqlalchemy.engine import Connection 10 | from sqlalchemy.ext.asyncio import async_engine_from_config 11 | 12 | from app.infrastructure.sqla_persistence.mappings.all import map_tables 13 | from app.infrastructure.sqla_persistence.orm_registry import mapping_registry 14 | from app.setup.config.settings import AppSettings, load_settings 15 | 16 | # this is the Alembic Config object, which provides 17 | # access to the values within the .ini file in use. 18 | config = context.config 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | if config.config_file_name is not None: 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | # from myapp import mymodel 28 | # target_metadata = mymodel.Base.metadata 29 | map_tables() 30 | target_metadata = mapping_registry.metadata 31 | 32 | # other values from the config, defined by the needs of env.py, 33 | # can be acquired: 34 | # my_important_option = config.get_main_option("my_important_option") 35 | # ... etc. 36 | settings: AppSettings = load_settings() 37 | 38 | config.set_main_option("sqlalchemy.url", settings.postgres.dsn) 39 | 40 | 41 | def run_migrations_offline() -> None: 42 | """Run migrations in 'offline' mode. 43 | 44 | This configures the context with just a URL 45 | and not an Engine, though an Engine is acceptable 46 | here as well. By skipping the Engine creation 47 | we don't even need a DBAPI to be available. 48 | 49 | Calls to context.execute() here emit the given string to the 50 | script output. 51 | 52 | """ 53 | url = config.get_main_option("sqlalchemy.url") 54 | context.configure( 55 | url=url, 56 | target_metadata=target_metadata, 57 | literal_binds=True, 58 | dialect_opts={"paramstyle": "named"}, 59 | ) 60 | 61 | with context.begin_transaction(): 62 | context.run_migrations() 63 | 64 | 65 | def do_run_migrations(connection: Connection) -> None: 66 | context.configure(connection=connection, target_metadata=target_metadata) 67 | 68 | with context.begin_transaction(): 69 | context.run_migrations() 70 | 71 | 72 | async def run_async_migrations() -> None: 73 | """In this scenario we need to create an Engine 74 | and associate a connection with the context. 75 | 76 | """ 77 | 78 | connectable = async_engine_from_config( 79 | config.get_section(config.config_ini_section, {}), 80 | prefix="sqlalchemy.", 81 | poolclass=pool.NullPool, 82 | ) 83 | 84 | async with connectable.connect() as connection: 85 | await connection.run_sync(do_run_migrations) 86 | 87 | await connectable.dispose() 88 | 89 | 90 | def run_migrations_online() -> None: 91 | """Run migrations in 'online' mode.""" 92 | 93 | asyncio.run(run_async_migrations()) 94 | 95 | 96 | if context.is_offline_mode(): 97 | run_migrations_offline() 98 | else: 99 | run_migrations_online() 100 | -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/alembic/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 typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/alembic/versions/2025_05_24_1838-c8bc1a6d7a66_users_auth.py: -------------------------------------------------------------------------------- 1 | """users,auth 2 | 3 | Revision ID: c8bc1a6d7a66 4 | Revises: 5 | Create Date: 2025-05-24 18:38:56.832267 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | from sqlalchemy.dialects import postgresql 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "c8bc1a6d7a66" 17 | down_revision: Union[str, None] = None 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | sa.Enum("SUPER_ADMIN", "ADMIN", "USER", name="userrole").create(op.get_bind()) 24 | op.create_table( 25 | "auth_sessions", 26 | sa.Column("id", sa.String(), nullable=False), 27 | sa.Column("user_id", sa.UUID(), nullable=False), 28 | sa.Column("expiration", sa.DateTime(timezone=True), nullable=False), 29 | sa.PrimaryKeyConstraint("id", name=op.f("pk_auth_sessions")), 30 | ) 31 | op.create_table( 32 | "users", 33 | sa.Column("id", sa.UUID(), nullable=False), 34 | sa.Column("username", sa.String(length=20), nullable=False), 35 | sa.Column("password_hash", sa.LargeBinary(), nullable=False), 36 | sa.Column( 37 | "role", 38 | postgresql.ENUM( 39 | "SUPER_ADMIN", "ADMIN", "USER", name="userrole", create_type=False 40 | ), 41 | nullable=False, 42 | ), 43 | sa.Column("is_active", sa.Boolean(), nullable=False), 44 | sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), 45 | sa.UniqueConstraint("username", name=op.f("uq_users_username")), 46 | ) 47 | # ### end Alembic commands ### 48 | 49 | 50 | def downgrade() -> None: 51 | op.drop_table("users") 52 | op.drop_table("auth_sessions") 53 | sa.Enum("SUPER_ADMIN", "ADMIN", "USER", name="userrole").drop(op.get_bind()) 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/alembic/versions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/sqla_persistence/alembic/versions/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/mappings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/infrastructure/sqla_persistence/mappings/__init__.py -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/mappings/all.py: -------------------------------------------------------------------------------- 1 | """ 2 | Ensures imperative SQLAlchemy mappings are initialized at application startup. 3 | 4 | ### Purpose: 5 | In Clean Architecture, domain entities remain agnostic of database 6 | mappings. To integrate with SQLAlchemy, mappings must be explicitly 7 | triggered to link ORM attributes to domain classes. Without this setup, 8 | attempts to interact with unmapped entities in database operations 9 | will lead to runtime errors. 10 | 11 | ### Solution: 12 | This module provides a single entry point to initialize the mapping 13 | of domain entities to database tables. By calling the `map_tables` function, 14 | ORM attributes are linked to domain classes without altering domain code 15 | or introducing infrastructure concerns. 16 | 17 | ### Usage: 18 | Call the `map_tables` function in the application factory to initialize 19 | mappings at startup. Additionally, it is necessary to call this function 20 | in `env.py` for Alembic migrations to ensure all models are available 21 | during database migrations. 22 | """ 23 | 24 | from app.infrastructure.sqla_persistence.mappings.auth_context_session import ( 25 | map_auth_sessions_table, 26 | ) 27 | from app.infrastructure.sqla_persistence.mappings.user import map_users_table 28 | 29 | 30 | def map_tables() -> None: 31 | map_users_table() 32 | map_auth_sessions_table() 33 | -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/mappings/auth_context_session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import UUID, Column, DateTime, String, Table 2 | from sqlalchemy.orm import composite 3 | 4 | from app.domain.value_objects.user_id import UserId 5 | from app.infrastructure.auth_context.common.auth_session import AuthSession 6 | from app.infrastructure.sqla_persistence.orm_registry import mapping_registry 7 | 8 | auth_sessions_table = Table( 9 | "auth_sessions", 10 | mapping_registry.metadata, 11 | Column("id", String, primary_key=True), 12 | Column("user_id", UUID(as_uuid=True), nullable=False), 13 | Column("expiration", DateTime(timezone=True), nullable=False), 14 | ) 15 | 16 | 17 | def map_auth_sessions_table() -> None: 18 | mapping_registry.map_imperatively( 19 | AuthSession, 20 | auth_sessions_table, 21 | properties={ 22 | "id_": auth_sessions_table.c.id, 23 | "user_id": composite(UserId, auth_sessions_table.c.user_id), 24 | "expiration": auth_sessions_table.c.expiration, 25 | }, 26 | column_prefix="_", 27 | ) 28 | -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/mappings/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import UUID, Boolean, Column, Enum, LargeBinary, String, Table 2 | from sqlalchemy.orm import composite 3 | 4 | from app.domain.entities.user import User 5 | from app.domain.enums.user_role import UserRole 6 | from app.domain.value_objects.user_id import UserId 7 | from app.domain.value_objects.user_password_hash import UserPasswordHash 8 | from app.domain.value_objects.username.constants import USERNAME_MAX_LEN 9 | from app.domain.value_objects.username.username import Username 10 | from app.infrastructure.sqla_persistence.orm_registry import mapping_registry 11 | 12 | users_table = Table( 13 | "users", 14 | mapping_registry.metadata, 15 | Column("id", UUID(as_uuid=True), primary_key=True), 16 | Column("username", String(USERNAME_MAX_LEN), nullable=False, unique=True), 17 | Column("password_hash", LargeBinary, nullable=False), 18 | Column( 19 | "role", 20 | Enum(UserRole), 21 | default=UserRole.USER, 22 | nullable=False, 23 | ), 24 | Column("is_active", Boolean, default=True, nullable=False), 25 | ) 26 | 27 | 28 | def map_users_table() -> None: 29 | mapping_registry.map_imperatively( 30 | User, 31 | users_table, 32 | properties={ 33 | "id_": composite(UserId, users_table.c.id), 34 | "username": composite(Username, users_table.c.username), 35 | "password_hash": composite(UserPasswordHash, users_table.c.password_hash), 36 | "role": users_table.c.role, 37 | "is_active": users_table.c.is_active, 38 | }, 39 | column_prefix="_", 40 | ) 41 | -------------------------------------------------------------------------------- /src/app/infrastructure/sqla_persistence/orm_registry.py: -------------------------------------------------------------------------------- 1 | from types import MappingProxyType 2 | from typing import Final 3 | 4 | from sqlalchemy import MetaData 5 | from sqlalchemy.orm import registry 6 | 7 | NAMING_CONVENTIONS: Final[MappingProxyType[str, str]] = MappingProxyType({ 8 | "ix": "ix_%(column_0_label)s", 9 | "uq": "uq_%(table_name)s_%(column_0_name)s", 10 | "ck": "ck_%(table_name)s_%(constraint_name)s", 11 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 12 | "pk": "pk_%(table_name)s", 13 | }) 14 | 15 | mapping_registry = registry(metadata=MetaData(naming_convention=NAMING_CONVENTIONS)) 16 | -------------------------------------------------------------------------------- /src/app/presentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/presentation/__init__.py -------------------------------------------------------------------------------- /src/app/presentation/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/presentation/common/__init__.py -------------------------------------------------------------------------------- /src/app/presentation/common/asgi_auth_middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from http.cookies import SimpleCookie 3 | from typing import Any, Literal 4 | 5 | from starlette.datastructures import MutableHeaders 6 | from starlette.requests import Request 7 | from starlette.types import ASGIApp, Message, Receive, Scope, Send 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class ASGIAuthMiddleware: 13 | def __init__(self, app: ASGIApp): 14 | self.app = app 15 | 16 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 17 | if scope["type"] != "http": 18 | return await self.app(scope, receive, send) 19 | 20 | request = Request(scope) 21 | 22 | async def modify_cookies(message: Message) -> None: 23 | if message["type"] == "http.response.start": 24 | headers = MutableHeaders(scope=message) 25 | self._set_access_token_cookie(request, headers) 26 | self._delete_access_token_cookie(request, headers) 27 | await send(message) 28 | 29 | return await self.app(scope, receive, modify_cookies) 30 | 31 | def _set_access_token_cookie( 32 | self, 33 | request: Request, 34 | headers: MutableHeaders, 35 | ) -> None: 36 | state = request.state 37 | new_access_token = getattr(state, "new_access_token", None) 38 | if new_access_token is None: 39 | return 40 | 41 | cookie_params: dict[str, Any] = getattr(request.state, "cookie_params", {}) 42 | is_cookie_secure: bool = cookie_params.get("secure", False) 43 | cookie_samesite: Literal["strict"] | None = cookie_params.get("samesite") 44 | 45 | cookie_header = self._make_cookie_header( 46 | value=new_access_token, 47 | is_secure=is_cookie_secure, 48 | samesite=cookie_samesite, 49 | ) 50 | headers.append("Set-Cookie", cookie_header) 51 | log.debug("Cookie with access token '%s' was set.", new_access_token) 52 | 53 | def _delete_access_token_cookie( 54 | self, 55 | request: Request, 56 | headers: MutableHeaders, 57 | ) -> None: 58 | is_delete_token: bool = getattr(request.state, "delete_access_token", False) 59 | if not is_delete_token: 60 | return 61 | 62 | current_access_token: str | None = request.cookies.get("access_token", None) 63 | log.debug( 64 | "Deleting cookie with access token '%s'.", 65 | current_access_token if current_access_token else "already deleted", 66 | ) 67 | 68 | cookie_header = self._make_cookie_header(value="", max_age=0) 69 | headers.append("Set-Cookie", cookie_header) 70 | log.debug("Cookie was deleted.") 71 | 72 | def _make_cookie_header( 73 | self, 74 | *, 75 | value: str, 76 | is_secure: bool = False, 77 | samesite: Literal["strict"] | None = None, 78 | max_age: int | None = None, 79 | ) -> str: 80 | cookie = SimpleCookie() 81 | cookie["access_token"] = value 82 | cookie["access_token"]["path"] = "/" 83 | cookie["access_token"]["httponly"] = True 84 | 85 | if is_secure: 86 | cookie["access_token"]["secure"] = True 87 | if samesite: 88 | cookie["access_token"]["samesite"] = samesite 89 | if max_age is not None: 90 | cookie["access_token"]["max-age"] = max_age 91 | 92 | return cookie.output(header="").strip() 93 | -------------------------------------------------------------------------------- /src/app/presentation/common/cookie_params.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Literal 3 | 4 | 5 | @dataclass(eq=False, slots=True, kw_only=True) 6 | class CookieParams: 7 | secure: bool 8 | samesite: Literal["strict"] | None = None 9 | -------------------------------------------------------------------------------- /src/app/presentation/common/exception_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from types import MappingProxyType 4 | from typing import Any, Final 5 | 6 | import pydantic 7 | from fastapi import FastAPI, status 8 | from fastapi.encoders import jsonable_encoder 9 | from fastapi.requests import Request 10 | from fastapi.responses import ORJSONResponse 11 | 12 | from app.application.common.exceptions.authorization import AuthorizationError 13 | from app.application.common.exceptions.base import ApplicationError 14 | from app.application.common.exceptions.sorting import SortingError 15 | from app.domain.exceptions.base import DomainError, DomainFieldError 16 | from app.domain.exceptions.user import ( 17 | ActivationChangeNotPermittedError, 18 | RoleChangeNotPermittedError, 19 | UsernameAlreadyExistsError, 20 | UserNotFoundByUsernameError, 21 | ) 22 | from app.infrastructure.auth_context.common.auth_exceptions import ( 23 | AlreadyAuthenticatedError, 24 | AuthenticationError, 25 | ) 26 | from app.infrastructure.exceptions.base import InfrastructureError 27 | 28 | log = logging.getLogger(__name__) 29 | 30 | 31 | @dataclass(frozen=True, slots=True) 32 | class ExceptionSchema: 33 | description: str 34 | 35 | 36 | @dataclass(frozen=True, slots=True) 37 | class ExceptionSchemaRich: 38 | description: str 39 | details: list[dict[str, Any]] | None = None 40 | 41 | 42 | class ExceptionHandler: 43 | _ERROR_MAPPING: Final[MappingProxyType[type[Exception], int]] = MappingProxyType({ 44 | # 400 45 | DomainFieldError: status.HTTP_400_BAD_REQUEST, 46 | SortingError: status.HTTP_400_BAD_REQUEST, 47 | # 401 48 | AuthenticationError: status.HTTP_401_UNAUTHORIZED, 49 | AlreadyAuthenticatedError: status.HTTP_401_UNAUTHORIZED, 50 | # 403 51 | AuthorizationError: status.HTTP_403_FORBIDDEN, 52 | ActivationChangeNotPermittedError: status.HTTP_403_FORBIDDEN, 53 | RoleChangeNotPermittedError: status.HTTP_403_FORBIDDEN, 54 | # 404 55 | UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND, 56 | # 409 57 | UsernameAlreadyExistsError: status.HTTP_409_CONFLICT, 58 | # 422 59 | pydantic.ValidationError: status.HTTP_422_UNPROCESSABLE_ENTITY, 60 | # 500 61 | DomainError: status.HTTP_500_INTERNAL_SERVER_ERROR, 62 | ApplicationError: status.HTTP_500_INTERNAL_SERVER_ERROR, 63 | InfrastructureError: status.HTTP_500_INTERNAL_SERVER_ERROR, 64 | }) 65 | 66 | def __init__(self, app: FastAPI): 67 | self._app = app 68 | 69 | async def _handle(self, _: Request, exc: Exception) -> ORJSONResponse: 70 | status_code: int = self._ERROR_MAPPING.get( 71 | type(exc), 72 | status.HTTP_500_INTERNAL_SERVER_ERROR, 73 | ) 74 | message = str(exc) if status_code < 500 else "Internal server error." 75 | 76 | if status_code >= 500: 77 | log.error( 78 | "Exception '%s' occurred: '%s'.", 79 | type(exc).__name__, 80 | exc, 81 | exc_info=exc, 82 | ) 83 | else: 84 | log.warning("Exception '%s' occurred: '%s'.", type(exc).__name__, exc) 85 | 86 | if isinstance(exc, pydantic.ValidationError): 87 | response: ExceptionSchema | ExceptionSchemaRich = ExceptionSchemaRich( 88 | message, 89 | jsonable_encoder(exc.errors()), 90 | ) 91 | else: 92 | response = ExceptionSchema(message) 93 | 94 | return ORJSONResponse( 95 | status_code=status_code, 96 | content=response, 97 | ) 98 | 99 | def setup_handlers(self) -> None: 100 | for exc_class in self._ERROR_MAPPING: 101 | self._app.add_exception_handler(exc_class, self._handle) 102 | self._app.add_exception_handler(Exception, self._handle) 103 | -------------------------------------------------------------------------------- /src/app/presentation/common/fastapi_dependencies.py: -------------------------------------------------------------------------------- 1 | from fastapi.security import APIKeyCookie 2 | 3 | # Token extraction marker for FastAPI Swagger UI. 4 | # The actual token processing will be handled behind the Identity Provider. 5 | cookie_scheme = APIKeyCookie(name="access_token") 6 | -------------------------------------------------------------------------------- /src/app/presentation/common/http_api_routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/presentation/common/http_api_routers/__init__.py -------------------------------------------------------------------------------- /src/app/presentation/common/http_api_routers/account.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.presentation.http_controllers.account_log_in import log_in_router 4 | from app.presentation.http_controllers.account_log_out import log_out_router 5 | from app.presentation.http_controllers.account_sign_up import sign_up_router 6 | 7 | account_router = APIRouter( 8 | prefix="/account", 9 | tags=["Account"], 10 | ) 11 | 12 | account_sub_routers: tuple[APIRouter, ...] = ( 13 | sign_up_router, 14 | log_in_router, 15 | log_out_router, 16 | ) 17 | 18 | for router in account_sub_routers: 19 | account_router.include_router(router) 20 | -------------------------------------------------------------------------------- /src/app/presentation/common/http_api_routers/api_v1.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi.requests import Request 3 | 4 | from app.presentation.common.http_api_routers.account import account_router 5 | from app.presentation.common.http_api_routers.user import users_router 6 | 7 | api_v1_router = APIRouter( 8 | prefix="/api/v1", 9 | ) 10 | 11 | 12 | @api_v1_router.get("/", tags=["General"]) 13 | async def healthcheck(_: Request) -> dict[str, str]: 14 | return {"status": "ok"} 15 | 16 | 17 | api_v1_sub_routers: tuple[APIRouter, ...] = ( 18 | account_router, 19 | users_router, 20 | ) 21 | 22 | for router in api_v1_sub_routers: 23 | api_v1_router.include_router(router) 24 | -------------------------------------------------------------------------------- /src/app/presentation/common/http_api_routers/root.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi.responses import RedirectResponse 3 | 4 | from app.presentation.common.http_api_routers.api_v1 import api_v1_router 5 | 6 | root_router = APIRouter() 7 | 8 | 9 | @root_router.get("/", tags=["General"]) 10 | async def redirect_to_docs() -> RedirectResponse: 11 | return RedirectResponse(url="docs/") 12 | 13 | 14 | root_sub_routers: tuple[APIRouter, ...] = (api_v1_router,) 15 | 16 | for router in root_sub_routers: 17 | root_router.include_router(router) 18 | -------------------------------------------------------------------------------- /src/app/presentation/common/http_api_routers/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.presentation.http_controllers.admin_create_user.controller import ( 4 | create_user_router, 5 | ) 6 | from app.presentation.http_controllers.admin_inactivate_user import ( 7 | inactivate_user_router, 8 | ) 9 | from app.presentation.http_controllers.admin_list_users.controller import ( 10 | list_users_router, 11 | ) 12 | from app.presentation.http_controllers.admin_reactivate_user import ( 13 | reactivate_user_router, 14 | ) 15 | from app.presentation.http_controllers.super_admin_grant_admin import grant_admin_router 16 | from app.presentation.http_controllers.super_admin_revoke_admin import ( 17 | revoke_admin_router, 18 | ) 19 | from app.presentation.http_controllers.user_change_password import ( 20 | change_password_router, 21 | ) 22 | 23 | users_router = APIRouter( 24 | prefix="/users", 25 | tags=["Users"], 26 | ) 27 | 28 | users_sub_routers: tuple[APIRouter, ...] = ( 29 | create_user_router, 30 | list_users_router, 31 | inactivate_user_router, 32 | reactivate_user_router, 33 | grant_admin_router, 34 | revoke_admin_router, 35 | change_password_router, 36 | ) 37 | 38 | for router in users_sub_routers: 39 | users_router.include_router(router) 40 | -------------------------------------------------------------------------------- /src/app/presentation/common/infrastructure_adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/presentation/common/infrastructure_adapters/__init__.py -------------------------------------------------------------------------------- /src/app/presentation/common/infrastructure_adapters/auth_context/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/presentation/common/infrastructure_adapters/auth_context/__init__.py -------------------------------------------------------------------------------- /src/app/presentation/common/infrastructure_adapters/auth_context/cookie_access_token_request_handler.py: -------------------------------------------------------------------------------- 1 | from fastapi.requests import Request 2 | 3 | from app.infrastructure.auth_context.common.ports.access_token_request_handler import ( 4 | AccessTokenRequestHandler, 5 | ) 6 | from app.presentation.common.cookie_params import CookieParams 7 | 8 | 9 | class CookieAccessTokenRequestHandler(AccessTokenRequestHandler): 10 | def __init__( 11 | self, 12 | request: Request, 13 | cookie_params: CookieParams, 14 | ): 15 | self._request = request 16 | self._cookie_params = cookie_params 17 | 18 | def get_access_token_from_request(self) -> str | None: 19 | return self._request.cookies.get("access_token") 20 | 21 | def add_access_token_to_request(self, new_access_token: str) -> None: 22 | self._request.state.new_access_token = new_access_token 23 | self._request.state.cookie_params = { 24 | "secure": self._cookie_params.secure, 25 | "samesite": self._cookie_params.samesite, 26 | } 27 | 28 | def delete_access_token_from_request(self) -> None: 29 | self._request.state.delete_access_token = True 30 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/presentation/http_controllers/__init__.py -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/account_log_in.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, status 4 | 5 | from app.infrastructure.auth_context.log_in import LogInHandler, LogInRequest 6 | from app.presentation.common.exception_handler import ( 7 | ExceptionSchema, 8 | ExceptionSchemaRich, 9 | ) 10 | 11 | log_in_router = APIRouter() 12 | 13 | 14 | @log_in_router.post( 15 | "/login", 16 | responses={ 17 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 18 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 19 | status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, 20 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 21 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 22 | }, 23 | status_code=status.HTTP_204_NO_CONTENT, 24 | ) 25 | @inject 26 | async def login( 27 | request_data: LogInRequest, 28 | interactor: FromDishka[LogInHandler], 29 | ) -> None: 30 | # :raises AlreadyAuthenticatedError 401: 31 | # :raises DataMapperError 500: 32 | # :raises DomainFieldError 400: 33 | # :raises UserNotFoundByUsername 404: 34 | await interactor(request_data) 35 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/account_log_out.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, Security, status 4 | 5 | from app.infrastructure.auth_context.log_out import LogOutHandler 6 | from app.presentation.common.exception_handler import ( 7 | ExceptionSchema, 8 | ExceptionSchemaRich, 9 | ) 10 | from app.presentation.common.fastapi_dependencies import cookie_scheme 11 | 12 | log_out_router = APIRouter() 13 | 14 | 15 | @log_out_router.delete( 16 | "/logout", 17 | responses={ 18 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 19 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 20 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 21 | }, 22 | status_code=status.HTTP_204_NO_CONTENT, 23 | dependencies=[Security(cookie_scheme)], 24 | ) 25 | @inject 26 | async def logout( 27 | interactor: FromDishka[LogOutHandler], 28 | ) -> None: 29 | # :raises AuthenticationError 401: 30 | # :raises DataMapperError 500: 31 | await interactor() 32 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/account_sign_up.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, status 4 | 5 | from app.infrastructure.auth_context.sign_up import ( 6 | SignUpHandler, 7 | SignUpRequest, 8 | SignUpResponse, 9 | ) 10 | from app.presentation.common.exception_handler import ( 11 | ExceptionSchema, 12 | ExceptionSchemaRich, 13 | ) 14 | 15 | sign_up_router = APIRouter() 16 | 17 | 18 | @sign_up_router.post( 19 | "/signup", 20 | responses={ 21 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 22 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 23 | status.HTTP_409_CONFLICT: {"model": ExceptionSchema}, 24 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 25 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 26 | }, 27 | status_code=status.HTTP_201_CREATED, 28 | ) 29 | @inject 30 | async def sign_up( 31 | request_data: SignUpRequest, 32 | interactor: FromDishka[SignUpHandler], 33 | ) -> SignUpResponse: 34 | # :raises AlreadyAuthenticatedError 401: 35 | # :raises DomainFieldError 400: 36 | # :raises DataMapperError 500: 37 | # :raises UsernameAlreadyExists 409: 38 | return await interactor(request_data) 39 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/admin_create_user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/presentation/http_controllers/admin_create_user/__init__.py -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/admin_create_user/controller.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, Security, status 4 | 5 | from app.application.commands.admin_create_user import ( 6 | CreateUserInteractor, 7 | CreateUserRequest, 8 | CreateUserResponse, 9 | ) 10 | from app.presentation.common.exception_handler import ( 11 | ExceptionSchema, 12 | ExceptionSchemaRich, 13 | ) 14 | from app.presentation.common.fastapi_dependencies import cookie_scheme 15 | from app.presentation.http_controllers.admin_create_user.pydantic_schema import ( 16 | CreateUserRequestPydantic, 17 | ) 18 | 19 | create_user_router = APIRouter() 20 | 21 | 22 | @create_user_router.post( 23 | "/", 24 | responses={ 25 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 26 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 27 | status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, 28 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 29 | status.HTTP_409_CONFLICT: {"model": ExceptionSchema}, 30 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 31 | }, 32 | status_code=status.HTTP_201_CREATED, 33 | dependencies=[Security(cookie_scheme)], 34 | ) 35 | @inject 36 | async def create_user( 37 | request_data_pydantic: CreateUserRequestPydantic, 38 | interactor: FromDishka[CreateUserInteractor], 39 | ) -> CreateUserResponse: 40 | # :raises AuthenticationError 401: 41 | # :raises DataMapperError 500: 42 | # :raises AuthorizationError 403: 43 | # :raises DomainFieldError 400: 44 | # :raises UsernameAlreadyExists 409: 45 | request_data = CreateUserRequest( 46 | username=request_data_pydantic.username, 47 | password=request_data_pydantic.password, 48 | role=request_data_pydantic.role, 49 | ) 50 | return await interactor(request_data) 51 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/admin_create_user/pydantic_schema.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict, Field 2 | 3 | from app.domain.enums.user_role import UserRole 4 | 5 | 6 | class CreateUserRequestPydantic(BaseModel): 7 | """ 8 | Using a Pydantic model here is generally unnecessary. 9 | It's only implemented to render a specific Swagger UI (OpenAPI) schema. 10 | """ 11 | 12 | model_config = ConfigDict(frozen=True) 13 | 14 | username: str 15 | password: str 16 | role: UserRole = Field(default=UserRole.USER) 17 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/admin_inactivate_user.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, Security, status 4 | 5 | from app.application.commands.admin_inactivate_user import ( 6 | InactivateUserInteractor, 7 | InactivateUserRequest, 8 | ) 9 | from app.presentation.common.exception_handler import ( 10 | ExceptionSchema, 11 | ExceptionSchemaRich, 12 | ) 13 | from app.presentation.common.fastapi_dependencies import cookie_scheme 14 | 15 | inactivate_user_router = APIRouter() 16 | 17 | 18 | @inactivate_user_router.patch( 19 | "/inactivate", 20 | responses={ 21 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 22 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 23 | status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, 24 | status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, 25 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 26 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 27 | }, 28 | status_code=status.HTTP_204_NO_CONTENT, 29 | dependencies=[Security(cookie_scheme)], 30 | ) 31 | @inject 32 | async def inactivate_user( 33 | request_data: InactivateUserRequest, 34 | interactor: FromDishka[InactivateUserInteractor], 35 | ) -> None: 36 | # :raises AuthenticationError 401: 37 | # :raises DataMapperError 500: 38 | # :raises AuthorizationError 403: 39 | # :raises DomainFieldError 400: 40 | # :raises UserNotFoundByUsername 404: 41 | # :raises ActivationChangeNotPermitted 403: 42 | await interactor(request_data) 43 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/admin_list_users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/presentation/http_controllers/admin_list_users/__init__.py -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/admin_list_users/controller.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from dishka import FromDishka 4 | from dishka.integrations.fastapi import inject 5 | from fastapi import APIRouter, Depends, Security, status 6 | 7 | from app.application.queries.admin_list_users import ( 8 | ListUsersQueryService, 9 | ListUsersRequest, 10 | ListUsersResponse, 11 | ) 12 | from app.presentation.common.exception_handler import ( 13 | ExceptionSchema, 14 | ExceptionSchemaRich, 15 | ) 16 | from app.presentation.common.fastapi_dependencies import cookie_scheme 17 | from app.presentation.http_controllers.admin_list_users.pydantic_schema import ( 18 | ListUsersRequestPydantic, 19 | ) 20 | 21 | list_users_router = APIRouter() 22 | 23 | 24 | @list_users_router.get( 25 | "/", 26 | responses={ 27 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 28 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 29 | status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, 30 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 31 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 32 | }, 33 | status_code=status.HTTP_200_OK, 34 | dependencies=[Security(cookie_scheme)], 35 | ) 36 | @inject 37 | async def list_users( 38 | request_data_pydantic: Annotated[ListUsersRequestPydantic, Depends()], 39 | interactor: FromDishka[ListUsersQueryService], 40 | ) -> ListUsersResponse: 41 | # :raises AuthenticationError 401: 42 | # :raises DataMapperError 500: 43 | # :raises AuthorizationError 403: 44 | # :raises PaginationError 500: 45 | # :raises ReaderError 500: 46 | # :raises SortingError 400: 47 | request_data = ListUsersRequest( 48 | limit=request_data_pydantic.limit, 49 | offset=request_data_pydantic.offset, 50 | sorting_field=request_data_pydantic.sorting_field, 51 | sorting_order=request_data_pydantic.sorting_order, 52 | ) 53 | return await interactor(request_data) 54 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/admin_list_users/pydantic_schema.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from pydantic import BaseModel, ConfigDict, Field 4 | 5 | from app.application.common.query_filters.sorting_order_enum import SortingOrder 6 | 7 | 8 | class ListUsersRequestPydantic(BaseModel): 9 | """ 10 | Using a Pydantic model here is generally unnecessary. 11 | It's only implemented to render a specific Swagger UI (OpenAPI) schema. 12 | """ 13 | 14 | model_config = ConfigDict(frozen=True) 15 | 16 | limit: Annotated[int, Field(ge=1)] = 20 17 | offset: Annotated[int, Field(ge=0)] = 0 18 | sorting_field: Annotated[str | None, Field()] = None 19 | sorting_order: Annotated[SortingOrder | None, Field()] = None 20 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/admin_reactivate_user.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, Security, status 4 | 5 | from app.application.commands.admin_reactivate_user import ( 6 | ReactivateUserInteractor, 7 | ReactivateUserRequest, 8 | ) 9 | from app.presentation.common.exception_handler import ( 10 | ExceptionSchema, 11 | ExceptionSchemaRich, 12 | ) 13 | from app.presentation.common.fastapi_dependencies import cookie_scheme 14 | 15 | reactivate_user_router = APIRouter() 16 | 17 | 18 | @reactivate_user_router.patch( 19 | "/reactivate", 20 | responses={ 21 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 22 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 23 | status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, 24 | status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, 25 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 26 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 27 | }, 28 | status_code=status.HTTP_204_NO_CONTENT, 29 | dependencies=[Security(cookie_scheme)], 30 | ) 31 | @inject 32 | async def reactivate_user( 33 | request_data: ReactivateUserRequest, 34 | interactor: FromDishka[ReactivateUserInteractor], 35 | ) -> None: 36 | # :raises AuthenticationError 401: 37 | # :raises DataMapperError 500: 38 | # :raises AuthorizationError 403: 39 | # :raises DomainFieldError 400: 40 | # :raises UserNotFoundByUsername 404: 41 | # :raises ActivationChangeNotPermitted 403: 42 | await interactor(request_data) 43 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/super_admin_grant_admin.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, Security, status 4 | 5 | from app.application.commands.super_admin_grant_admin import ( 6 | GrantAdminInteractor, 7 | GrantAdminRequest, 8 | ) 9 | from app.presentation.common.exception_handler import ( 10 | ExceptionSchema, 11 | ExceptionSchemaRich, 12 | ) 13 | from app.presentation.common.fastapi_dependencies import cookie_scheme 14 | 15 | grant_admin_router = APIRouter() 16 | 17 | 18 | @grant_admin_router.patch( 19 | "/grant", 20 | responses={ 21 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 22 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 23 | status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, 24 | status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, 25 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 26 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 27 | }, 28 | status_code=status.HTTP_204_NO_CONTENT, 29 | dependencies=[Security(cookie_scheme)], 30 | ) 31 | @inject 32 | async def grant_admin( 33 | request_data: GrantAdminRequest, 34 | interactor: FromDishka[GrantAdminInteractor], 35 | ) -> None: 36 | # :raises AuthenticationError 401: 37 | # :raises DataMapperError 500: 38 | # :raises AuthorizationError 403: 39 | # :raises DomainFieldError 400: 40 | # :raises UserNotFoundByUsername 404: 41 | # :raises RoleChangeNotPermitted 403: 42 | await interactor(request_data) 43 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/super_admin_revoke_admin.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, Security, status 4 | 5 | from app.application.commands.super_admin_revoke_admin import ( 6 | RevokeAdminInteractor, 7 | RevokeAdminRequest, 8 | ) 9 | from app.presentation.common.exception_handler import ( 10 | ExceptionSchema, 11 | ExceptionSchemaRich, 12 | ) 13 | from app.presentation.common.fastapi_dependencies import cookie_scheme 14 | 15 | revoke_admin_router = APIRouter() 16 | 17 | 18 | @revoke_admin_router.patch( 19 | "/revoke", 20 | responses={ 21 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 22 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 23 | status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, 24 | status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, 25 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 26 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 27 | }, 28 | status_code=status.HTTP_204_NO_CONTENT, 29 | dependencies=[Security(cookie_scheme)], 30 | ) 31 | @inject 32 | async def revoke_admin( 33 | request_data: RevokeAdminRequest, 34 | interactor: FromDishka[RevokeAdminInteractor], 35 | ) -> None: 36 | # :raises AuthenticationError 401: 37 | # :raises DataMapperError 500: 38 | # :raises AuthorizationError 403: 39 | # :raises DomainFieldError 400: 40 | # :raises UserNotFoundByUsername 404: 41 | # :raises RoleChangeNotPermitted 403: 42 | await interactor(request_data) 43 | -------------------------------------------------------------------------------- /src/app/presentation/http_controllers/user_change_password.py: -------------------------------------------------------------------------------- 1 | from dishka import FromDishka 2 | from dishka.integrations.fastapi import inject 3 | from fastapi import APIRouter, Security, status 4 | 5 | from app.application.commands.user_change_password import ( 6 | ChangePasswordInteractor, 7 | ChangePasswordRequest, 8 | ) 9 | from app.presentation.common.exception_handler import ( 10 | ExceptionSchema, 11 | ExceptionSchemaRich, 12 | ) 13 | from app.presentation.common.fastapi_dependencies import cookie_scheme 14 | 15 | change_password_router = APIRouter() 16 | 17 | 18 | @change_password_router.patch( 19 | "/change-password", 20 | responses={ 21 | status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, 22 | status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, 23 | status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, 24 | status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, 25 | status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaRich}, 26 | status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, 27 | }, 28 | status_code=status.HTTP_204_NO_CONTENT, 29 | dependencies=[Security(cookie_scheme)], 30 | ) 31 | @inject 32 | async def change_password( 33 | request_data: ChangePasswordRequest, 34 | interactor: FromDishka[ChangePasswordInteractor], 35 | ) -> None: 36 | # :raises AuthenticationError 401: 37 | # :raises DataMapperError 500: 38 | # :raises AuthorizationError 403: 39 | # :raises DomainFieldError 400: 40 | # :raises UserNotFoundByUsername 404: 41 | await interactor(request_data) 42 | -------------------------------------------------------------------------------- /src/app/run.py: -------------------------------------------------------------------------------- 1 | from dishka import Provider 2 | from dishka.integrations.fastapi import setup_dishka 3 | from fastapi import FastAPI 4 | 5 | from app.presentation.common.http_api_routers.root import root_router 6 | from app.setup.app_factory import configure_app, create_app, create_async_ioc_container 7 | from app.setup.config.logs import configure_logging 8 | from app.setup.config.settings import AppSettings, load_settings 9 | from app.setup.ioc.registry import get_providers 10 | 11 | 12 | def make_app( 13 | *di_providers: Provider, 14 | settings: AppSettings | None = None, 15 | ) -> FastAPI: 16 | if settings is None: 17 | configure_logging() 18 | settings = load_settings() 19 | 20 | configure_logging(level=settings.logs.level) 21 | 22 | app: FastAPI = create_app() 23 | configure_app(app=app, root_router=root_router) 24 | 25 | async_ioc_container = create_async_ioc_container( 26 | providers=(*get_providers(), *di_providers), 27 | settings=settings, 28 | ) 29 | setup_dishka(container=async_ioc_container, app=app) 30 | 31 | return app 32 | 33 | 34 | if __name__ == "__main__": 35 | import uvicorn 36 | 37 | uvicorn.run( 38 | app=make_app(), 39 | port=8000, 40 | reload=False, 41 | loop="uvloop", 42 | ) 43 | -------------------------------------------------------------------------------- /src/app/setup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/setup/__init__.py -------------------------------------------------------------------------------- /src/app/setup/app_factory.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | "configure_app", 3 | "create_app", 4 | "create_async_ioc_container", 5 | ) 6 | 7 | from collections.abc import AsyncIterator, Iterable 8 | from contextlib import asynccontextmanager 9 | 10 | from dishka import AsyncContainer, Provider, make_async_container 11 | from fastapi import APIRouter, FastAPI 12 | from fastapi.responses import ORJSONResponse 13 | 14 | from app.infrastructure.sqla_persistence.mappings.all import map_tables 15 | from app.presentation.common.asgi_auth_middleware import ASGIAuthMiddleware 16 | from app.presentation.common.exception_handler import ExceptionHandler 17 | from app.setup.config.settings import AppSettings 18 | 19 | 20 | def create_app() -> FastAPI: 21 | return FastAPI( 22 | lifespan=lifespan, 23 | default_response_class=ORJSONResponse, 24 | ) 25 | 26 | 27 | @asynccontextmanager 28 | async def lifespan(app: FastAPI) -> AsyncIterator[None]: 29 | map_tables() 30 | yield None 31 | await app.state.dishka_container.close() # noqa 32 | # https://dishka.readthedocs.io/en/stable/integrations/fastapi.html 33 | 34 | 35 | def configure_app( 36 | app: FastAPI, 37 | root_router: APIRouter, 38 | ) -> None: 39 | app.include_router(root_router) 40 | app.add_middleware(ASGIAuthMiddleware) # noqa 41 | # https://github.com/encode/starlette/discussions/2451 42 | exception_handler = ExceptionHandler(app) 43 | exception_handler.setup_handlers() 44 | 45 | 46 | def create_async_ioc_container( 47 | providers: Iterable[Provider], 48 | settings: AppSettings, 49 | ) -> AsyncContainer: 50 | return make_async_container( 51 | *providers, 52 | context={AppSettings: settings}, 53 | ) 54 | -------------------------------------------------------------------------------- /src/app/setup/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/setup/config/__init__.py -------------------------------------------------------------------------------- /src/app/setup/config/constants.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | from pathlib import Path 3 | from types import MappingProxyType 4 | from typing import Final 5 | 6 | ENV_VAR_NAME: Final[str] = "APP_ENV" 7 | 8 | 9 | class ValidEnvs(StrEnum): 10 | """ 11 | Values should reflect actual directory names. 12 | """ 13 | 14 | LOCAL = "local" 15 | DEV = "dev" 16 | PROD = "prod" 17 | 18 | 19 | class DirContents(StrEnum): 20 | """ 21 | Values should reflect actual file names. 22 | """ 23 | 24 | CONFIG_NAME = "config.toml" 25 | SECRETS_NAME = ".secrets.toml" 26 | EXPORT_NAME = "export.toml" 27 | DOTENV_NAME = ".env" 28 | 29 | 30 | BASE_DIR_PATH: Final[Path] = Path(__file__).resolve().parent.parent.parent.parent.parent 31 | CONFIG_PATH: Final[Path] = BASE_DIR_PATH / "config" 32 | 33 | 34 | ENV_TO_DIR_PATHS: Final[MappingProxyType[ValidEnvs, Path]] = MappingProxyType({ 35 | ValidEnvs.LOCAL: CONFIG_PATH / ValidEnvs.LOCAL, 36 | ValidEnvs.DEV: CONFIG_PATH / ValidEnvs.DEV, 37 | ValidEnvs.PROD: CONFIG_PATH / ValidEnvs.PROD, 38 | }) 39 | -------------------------------------------------------------------------------- /src/app/setup/config/logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import StrEnum 3 | from typing import Final 4 | 5 | 6 | class LoggingLevel(StrEnum): 7 | DEBUG = "DEBUG" 8 | INFO = "INFO" 9 | WARNING = "WARNING" 10 | ERROR = "ERROR" 11 | CRITICAL = "CRITICAL" 12 | 13 | 14 | DEFAULT_LOG_LEVEL: Final[LoggingLevel] = LoggingLevel.INFO 15 | 16 | 17 | def configure_logging(*, level: LoggingLevel = DEFAULT_LOG_LEVEL) -> None: 18 | logging.getLogger().handlers.clear() 19 | 20 | level_map: dict[LoggingLevel, int] = { 21 | LoggingLevel.DEBUG: logging.DEBUG, 22 | LoggingLevel.INFO: logging.INFO, 23 | LoggingLevel.WARNING: logging.WARNING, 24 | LoggingLevel.ERROR: logging.ERROR, 25 | LoggingLevel.CRITICAL: logging.CRITICAL, 26 | } 27 | 28 | logging.basicConfig( 29 | level=level_map[level], 30 | datefmt="%Y-%m-%d %H:%M:%S", 31 | format=( 32 | "[%(asctime)s.%(msecs)03d] " 33 | "%(funcName)20s " 34 | "%(module)s:%(lineno)d " 35 | "%(levelname)-8s - " 36 | "%(message)s" 37 | ), 38 | ) 39 | -------------------------------------------------------------------------------- /src/app/setup/ioc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/setup/ioc/__init__.py -------------------------------------------------------------------------------- /src/app/setup/ioc/di_providers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/src/app/setup/ioc/di_providers/__init__.py -------------------------------------------------------------------------------- /src/app/setup/ioc/di_providers/application.py: -------------------------------------------------------------------------------- 1 | from dishka import Provider, Scope, provide, provide_all 2 | 3 | from app.application.commands.admin_create_user import CreateUserInteractor 4 | from app.application.commands.admin_inactivate_user import InactivateUserInteractor 5 | from app.application.commands.admin_reactivate_user import ReactivateUserInteractor 6 | from app.application.commands.super_admin_grant_admin import GrantAdminInteractor 7 | from app.application.commands.super_admin_revoke_admin import RevokeAdminInteractor 8 | from app.application.commands.user_change_password import ChangePasswordInteractor 9 | from app.application.common.ports.access_revoker import AccessRevoker 10 | from app.application.common.ports.command_gateways.user import UserCommandGateway 11 | from app.application.common.ports.identity_provider import IdentityProvider 12 | from app.application.common.ports.query_gateways.user import UserQueryGateway 13 | from app.application.common.ports.transaction_manager import TransactionManager 14 | from app.application.common.services.authorization import AuthorizationService 15 | from app.application.common.services.current_user import CurrentUserService 16 | from app.application.queries.admin_list_users import ListUsersQueryService 17 | from app.infrastructure.adapters.application.sqla_user_data_mapper import ( 18 | SqlaUserDataMapper, 19 | ) 20 | from app.infrastructure.adapters.application.sqla_user_reader import SqlaUserReader 21 | from app.infrastructure.adapters.application.sqla_user_transaction_manager import ( 22 | SqlaUserTransactionManager, 23 | ) 24 | from app.infrastructure.auth_context.common.application_adapters.auth_session_access_revoker import ( 25 | AuthSessionAccessRevoker, 26 | ) 27 | from app.infrastructure.auth_context.common.application_adapters.auth_session_identity_provider import ( 28 | AuthSessionIdentityProvider, 29 | ) 30 | 31 | 32 | class UserApplicationProvider(Provider): 33 | scope = Scope.REQUEST 34 | 35 | # Services 36 | services = provide_all( 37 | AuthorizationService, 38 | CurrentUserService, 39 | ) 40 | 41 | # Ports 42 | user_transaction_manager = provide( 43 | source=SqlaUserTransactionManager, 44 | provides=TransactionManager, 45 | ) 46 | auth_session_identity_provider = provide( 47 | source=AuthSessionIdentityProvider, 48 | provides=IdentityProvider, 49 | ) 50 | access_revoker = provide( 51 | source=AuthSessionAccessRevoker, 52 | provides=AccessRevoker, 53 | ) 54 | 55 | # Gateways 56 | user_command_gateway = provide( 57 | source=SqlaUserDataMapper, 58 | provides=UserCommandGateway, 59 | ) 60 | user_query_gateway = provide( 61 | source=SqlaUserReader, 62 | provides=UserQueryGateway, 63 | ) 64 | 65 | # Interactors 66 | interactors = provide_all( 67 | CreateUserInteractor, 68 | GrantAdminInteractor, 69 | InactivateUserInteractor, 70 | ReactivateUserInteractor, 71 | RevokeAdminInteractor, 72 | ChangePasswordInteractor, 73 | ) 74 | query_services = provide_all( 75 | ListUsersQueryService, 76 | ) 77 | -------------------------------------------------------------------------------- /src/app/setup/ioc/di_providers/domain.py: -------------------------------------------------------------------------------- 1 | from dishka import Provider, Scope, provide 2 | 3 | from app.domain.ports.password_hasher import PasswordHasher 4 | from app.domain.ports.user_id_generator import UserIdGenerator 5 | from app.domain.services.user import UserService 6 | from app.infrastructure.adapters.domain.bcrypt_password_hasher import ( 7 | BcryptPasswordHasher, 8 | ) 9 | from app.infrastructure.adapters.domain.uuid_user_id_generator import ( 10 | UuidUserIdGenerator, 11 | ) 12 | 13 | 14 | class UserDomainProvider(Provider): 15 | scope = Scope.REQUEST 16 | 17 | # Services 18 | user_service = provide(source=UserService) 19 | 20 | # Ports 21 | user_id_generator = provide( 22 | source=UuidUserIdGenerator, 23 | provides=UserIdGenerator, 24 | ) 25 | password_hasher = provide( 26 | source=BcryptPasswordHasher, 27 | provides=PasswordHasher, 28 | ) 29 | -------------------------------------------------------------------------------- /src/app/setup/ioc/di_providers/settings.py: -------------------------------------------------------------------------------- 1 | from dishka import Provider, Scope, from_context, provide 2 | 3 | from app.infrastructure.adapters.domain.new_types import PasswordPepper 4 | from app.infrastructure.auth_context.common.new_types import ( 5 | AuthSessionRefreshThreshold, 6 | AuthSessionTtlMin, 7 | JwtAlgorithm, 8 | JwtSecret, 9 | ) 10 | from app.presentation.common.cookie_params import CookieParams 11 | from app.setup.config.settings import AppSettings, PostgresDsn, SqlaEngineSettings 12 | 13 | 14 | class CommonSettingsProvider(Provider): 15 | scope = Scope.APP 16 | 17 | settings = from_context(provides=AppSettings) 18 | 19 | @provide 20 | def provide_postgres_dsn(self, settings: AppSettings) -> PostgresDsn: 21 | return PostgresDsn(settings.postgres.dsn) 22 | 23 | @provide 24 | def provide_sqla_engine_settings(self, settings: AppSettings) -> SqlaEngineSettings: 25 | return settings.sqla 26 | 27 | 28 | class UserSettingsProvider(Provider): 29 | scope = Scope.APP 30 | 31 | @provide 32 | def provide_password_pepper(self, settings: AppSettings) -> PasswordPepper: 33 | return PasswordPepper(settings.security.password.pepper) 34 | 35 | 36 | class AuthSettingsProvider(Provider): 37 | scope = Scope.APP 38 | 39 | @provide 40 | def provide_jwt_secret(self, settings: AppSettings) -> JwtSecret: 41 | return JwtSecret(settings.security.auth.jwt_secret) 42 | 43 | @provide 44 | def provide_jwt_algorithm(self, settings: AppSettings) -> JwtAlgorithm: 45 | return settings.security.auth.jwt_algorithm 46 | 47 | @provide 48 | def provide_auth_session_ttl_min(self, settings: AppSettings) -> AuthSessionTtlMin: 49 | return AuthSessionTtlMin(settings.security.auth.session_ttl_min) 50 | 51 | @provide 52 | def provide_auth_session_refresh_threshold( 53 | self, 54 | settings: AppSettings, 55 | ) -> AuthSessionRefreshThreshold: 56 | return AuthSessionRefreshThreshold( 57 | settings.security.auth.session_refresh_threshold, 58 | ) 59 | 60 | @provide 61 | def provide_cookie_params(self, settings: AppSettings) -> CookieParams: 62 | is_cookies_secure: bool = settings.security.cookies.secure 63 | if is_cookies_secure: 64 | return CookieParams(secure=True, samesite="strict") 65 | return CookieParams(secure=False) 66 | -------------------------------------------------------------------------------- /src/app/setup/ioc/registry.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | from dishka import Provider 4 | 5 | from app.setup.ioc.di_providers.application import UserApplicationProvider 6 | from app.setup.ioc.di_providers.domain import UserDomainProvider 7 | from app.setup.ioc.di_providers.infrastructure import ( 8 | AuthInfrastructureProvider, 9 | CommonInfrastructureProvider, 10 | UserInfrastructureProvider, 11 | ) 12 | from app.setup.ioc.di_providers.settings import ( 13 | AuthSettingsProvider, 14 | CommonSettingsProvider, 15 | UserSettingsProvider, 16 | ) 17 | 18 | 19 | def get_providers() -> Iterable[Provider]: 20 | return ( 21 | # Domain 22 | UserDomainProvider(), 23 | # Application 24 | UserApplicationProvider(), 25 | # Infrastructure 26 | CommonInfrastructureProvider(), 27 | UserInfrastructureProvider(), 28 | AuthInfrastructureProvider(), 29 | # Settings 30 | CommonSettingsProvider(), 31 | UserSettingsProvider(), 32 | AuthSettingsProvider(), 33 | ) 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/__init__.py -------------------------------------------------------------------------------- /tests/performance/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/performance/__init__.py -------------------------------------------------------------------------------- /tests/performance/profile_bcrypt_password_hasher.py: -------------------------------------------------------------------------------- 1 | from line_profiler import LineProfiler 2 | 3 | from app.domain.value_objects.raw_password.raw_password import RawPassword 4 | from app.infrastructure.adapters.domain.bcrypt_password_hasher import ( 5 | BcryptPasswordHasher, 6 | ) 7 | from app.infrastructure.adapters.domain.new_types import PasswordPepper 8 | 9 | 10 | def run_operations(hasher: BcryptPasswordHasher) -> None: 11 | raw_password = RawPassword("raw_password") 12 | hashed = hasher.hash(raw_password) 13 | hasher.verify(raw_password=raw_password, hashed_password=hashed) 14 | 15 | 16 | def setup_profiler() -> tuple[LineProfiler, BcryptPasswordHasher]: 17 | hasher = BcryptPasswordHasher(PasswordPepper("Cayenne!")) 18 | profiler = LineProfiler() 19 | 20 | profiler.add_function(hasher.hash) 21 | profiler.add_function(hasher.verify) 22 | profiler.add_function(run_operations) 23 | 24 | return profiler, hasher 25 | 26 | 27 | def main() -> None: 28 | profiler, hasher = setup_profiler() 29 | profiler.runcall(run_operations, hasher) # type: ignore[no-untyped-call] 30 | profiler.print_stats() 31 | 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/application/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/application/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/application/common/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/application/common/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/application/common/services/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/application/common/services/test_authorization.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.application.common.exceptions.authorization import AuthorizationError 4 | from app.application.common.services.authorization import AuthorizationService 5 | from app.domain.entities.user import User 6 | from app.domain.enums.user_role import UserRole 7 | 8 | 9 | def test_authorize_for_self(sample_user: User, other_sample_user: User) -> None: 10 | authz_service = AuthorizationService() 11 | current_user, target_user = sample_user, other_sample_user 12 | 13 | authz_service.authorize_for_self(current_user, target_user=current_user) 14 | 15 | with pytest.raises(AuthorizationError): 16 | authz_service.authorize_for_self(current_user, target_user=target_user) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | ("current_role", "target_role", "should_pass"), 21 | [ 22 | (UserRole.USER, UserRole.USER, False), 23 | (UserRole.USER, UserRole.ADMIN, False), 24 | (UserRole.USER, UserRole.SUPER_ADMIN, False), 25 | (UserRole.ADMIN, UserRole.USER, True), 26 | (UserRole.ADMIN, UserRole.ADMIN, False), 27 | (UserRole.ADMIN, UserRole.SUPER_ADMIN, False), 28 | (UserRole.SUPER_ADMIN, UserRole.USER, True), 29 | (UserRole.SUPER_ADMIN, UserRole.ADMIN, True), 30 | (UserRole.SUPER_ADMIN, UserRole.SUPER_ADMIN, False), 31 | ], 32 | ) 33 | def test_authorize_by_subordinate_role( 34 | current_role: UserRole, 35 | target_role: UserRole, 36 | should_pass: bool, 37 | sample_user: User, 38 | other_sample_user: User, 39 | ) -> None: 40 | authz_service = AuthorizationService() 41 | current_user, target_user = sample_user, other_sample_user 42 | current_user.role, target_user.role = current_role, target_role 43 | 44 | if should_pass: 45 | authz_service.authorize_for_subordinate_role( 46 | current_user, 47 | target_role=target_role, 48 | ) 49 | 50 | else: 51 | with pytest.raises(AuthorizationError): 52 | authz_service.authorize_for_subordinate_role( 53 | current_user, 54 | target_role=target_role, 55 | ) 56 | -------------------------------------------------------------------------------- /tests/unit/app/application/common/services/test_current_user.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import create_autospec 2 | 3 | import pytest 4 | 5 | from app.application.common.exceptions.authorization import AuthorizationError 6 | from app.application.common.ports.command_gateways.user import UserCommandGateway 7 | from app.application.common.ports.identity_provider import IdentityProvider 8 | from app.application.common.services.current_user import CurrentUserService 9 | from app.domain.entities.user import User 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_get_current_user_success(sample_user: User) -> None: 14 | identity_provider = create_autospec(IdentityProvider) 15 | user_gateway = create_autospec(UserCommandGateway) 16 | user_gateway.read_by_id.return_value = sample_user 17 | service = CurrentUserService(identity_provider, user_gateway) 18 | 19 | current_user = await service.get_current_user() 20 | 21 | assert current_user == sample_user 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_get_current_user_not_found() -> None: 26 | identity_provider = create_autospec(IdentityProvider) 27 | user_gateway = create_autospec(UserCommandGateway) 28 | user_gateway.read_by_id.return_value = None 29 | service = CurrentUserService(identity_provider, user_gateway) 30 | 31 | with pytest.raises(AuthorizationError): 32 | await service.get_current_user() 33 | -------------------------------------------------------------------------------- /tests/unit/app/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/domain/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/domain/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/domain/entities/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/domain/entities/test_base.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from app.domain.entities.base import Entity 6 | from app.domain.exceptions.base import DomainError 7 | from app.domain.value_objects.base import ValueObject 8 | 9 | 10 | @dataclass(frozen=True, slots=True, repr=False) 11 | class SingleFieldValueObject(ValueObject): 12 | value: int 13 | 14 | 15 | @dataclass(eq=False, slots=True) 16 | class SampleEntity(Entity[SingleFieldValueObject]): 17 | name: str 18 | 19 | 20 | def test_setattr(): 21 | entity = SampleEntity(id_=SingleFieldValueObject(value=123), name="abc") 22 | 23 | with pytest.raises(DomainError): 24 | entity.id_ = SingleFieldValueObject(value=456) 25 | 26 | 27 | def test_eq_hash(): 28 | entity_1 = SampleEntity(id_=SingleFieldValueObject(value=123), name="abc") 29 | entity_2 = SampleEntity(id_=SingleFieldValueObject(value=123), name="def") 30 | 31 | assert entity_1 == entity_2 32 | assert id(entity_1) != id(entity_2) 33 | assert hash(entity_1) == hash(entity_2) 34 | 35 | entity_3 = SampleEntity(id_=SingleFieldValueObject(value=456), name="abc") 36 | 37 | assert entity_1 != entity_3 38 | assert id(entity_1) != id(entity_3) 39 | assert hash(entity_1) != hash(entity_3) 40 | -------------------------------------------------------------------------------- /tests/unit/app/domain/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/domain/services/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/domain/value_objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/domain/value_objects/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/domain/value_objects/test_base.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from app.domain.exceptions.base import DomainFieldError 6 | from app.domain.value_objects.base import ValueObject 7 | 8 | 9 | @dataclass(frozen=True, slots=True, repr=False) 10 | class SingleFieldValueObject(ValueObject): 11 | value: int 12 | 13 | 14 | @dataclass(frozen=True, slots=True, repr=False) 15 | class MultiFieldValueObject(ValueObject): 16 | value1: int 17 | value2: str 18 | 19 | 20 | def test_post_init(): 21 | with pytest.raises(DomainFieldError): 22 | ValueObject() 23 | 24 | 25 | def test_repr(): 26 | vo_1 = SingleFieldValueObject(value=123) 27 | 28 | assert repr(vo_1) == "SingleFieldValueObject(123)" 29 | 30 | vo_2 = MultiFieldValueObject(value1=123, value2="abc") 31 | 32 | assert repr(vo_2) == "MultiFieldValueObject(value1=123, value2='abc')" 33 | 34 | 35 | def test_get_fields(): 36 | vo = MultiFieldValueObject(value1=123, value2="abc") 37 | 38 | assert vo.get_fields() == {"value1": 123, "value2": "abc"} 39 | -------------------------------------------------------------------------------- /tests/unit/app/domain/value_objects/test_raw_password.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.domain.exceptions.base import DomainFieldError 4 | from app.domain.value_objects.raw_password.constants import PASSWORD_MIN_LEN 5 | from app.domain.value_objects.raw_password.raw_password import RawPassword 6 | 7 | 8 | @pytest.mark.parametrize("password", ["a" * PASSWORD_MIN_LEN]) 9 | def test_vo_raw_password_valid_length(password): 10 | RawPassword(password) 11 | 12 | 13 | @pytest.mark.parametrize("password", ["a" * (PASSWORD_MIN_LEN - 1)]) 14 | def test_vo_raw_password_invalid_length(password: str) -> None: 15 | with pytest.raises(DomainFieldError): 16 | RawPassword(password) 17 | -------------------------------------------------------------------------------- /tests/unit/app/domain/value_objects/test_username.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.domain.exceptions.base import DomainFieldError 4 | from app.domain.value_objects.username.constants import ( 5 | USERNAME_MAX_LEN, 6 | USERNAME_MIN_LEN, 7 | ) 8 | from app.domain.value_objects.username.username import Username 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("username", "expected_exception"), 13 | [ 14 | pytest.param("a" * USERNAME_MIN_LEN, None, id="min_len"), 15 | pytest.param("a" * USERNAME_MAX_LEN, None, id="max_len"), 16 | pytest.param("a" * (USERNAME_MIN_LEN - 1), DomainFieldError, id="lt_min"), 17 | pytest.param("a" * (USERNAME_MAX_LEN + 1), DomainFieldError, id="gt_max"), 18 | ], 19 | ) 20 | def test_vo_username_length(username, expected_exception): 21 | if not expected_exception: 22 | Username(username) 23 | 24 | else: 25 | with pytest.raises(expected_exception): 26 | Username(username) 27 | 28 | 29 | @pytest.mark.parametrize( 30 | ("username", "expected_exception"), 31 | [ 32 | ("username", None), 33 | ("user.name", None), 34 | ("user-name", None), 35 | ("user_name", None), 36 | ("user123", None), 37 | ("user.name123", None), 38 | ("u.ser-name123", None), 39 | ("u-ser_name", None), 40 | ("u-ser.name", None), 41 | (".username", DomainFieldError), 42 | ("-username", DomainFieldError), 43 | ("_username", DomainFieldError), 44 | ("username.", DomainFieldError), 45 | ("username-", DomainFieldError), 46 | ("username_", DomainFieldError), 47 | ("user..name", DomainFieldError), 48 | ("user--name", DomainFieldError), 49 | ("user__name", DomainFieldError), 50 | ("user!name", DomainFieldError), 51 | ("user@name", DomainFieldError), 52 | ("user#name", DomainFieldError), 53 | ], 54 | ) 55 | def test_vo_username_correctness(username, expected_exception): 56 | if not expected_exception: 57 | Username(username) 58 | 59 | else: 60 | with pytest.raises(expected_exception): 61 | Username(username) 62 | -------------------------------------------------------------------------------- /tests/unit/app/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/infrastructure/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/infrastructure/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/infrastructure/adapters/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/infrastructure/adapters/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/infrastructure/adapters/domain/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/infrastructure/adapters/domain/test_password_hasher_bcrypt.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.domain.value_objects.raw_password.raw_password import RawPassword 4 | from app.infrastructure.adapters.domain.bcrypt_password_hasher import ( 5 | BcryptPasswordHasher, 6 | ) 7 | from app.infrastructure.adapters.domain.new_types import PasswordPepper 8 | 9 | 10 | def get_bcrypt_password_hasher() -> BcryptPasswordHasher: 11 | return BcryptPasswordHasher(PasswordPepper("Habanero!")) 12 | 13 | 14 | def test_bcrypt_password_hasher_init(): 15 | pepper = PasswordPepper("Serrano") 16 | hasher = BcryptPasswordHasher(pepper) 17 | 18 | assert hasher._pepper == pepper 19 | 20 | 21 | @pytest.mark.slow 22 | def test_bcrypt_password_hasher_hash() -> None: 23 | bcrypt_password_hasher: BcryptPasswordHasher = get_bcrypt_password_hasher() 24 | test_password = RawPassword("test_password") 25 | peppered1: bytes = bcrypt_password_hasher._add_pepper( 26 | test_password, 27 | bcrypt_password_hasher._pepper, 28 | ) 29 | peppered2: bytes = bcrypt_password_hasher._add_pepper( 30 | test_password, 31 | bcrypt_password_hasher._pepper, 32 | ) 33 | 34 | assert isinstance(peppered1, bytes) 35 | assert len(peppered1) == 44 # Base64 encoded SHA256 is always 44 bytes 36 | assert peppered1 == peppered2 37 | 38 | hash1: bytes = bcrypt_password_hasher.hash(test_password) 39 | hash2: bytes = bcrypt_password_hasher.hash(test_password) 40 | 41 | assert isinstance(hash1, bytes) 42 | assert hash1.startswith(b"$2b$") # bcrypt hash prefix 43 | assert hash1 != hash2 # hashes should be unique due to different salts 44 | 45 | 46 | @pytest.mark.slow 47 | def test_bcrypt_password_hasher_verify() -> None: 48 | bcrypt_password_hasher: BcryptPasswordHasher = get_bcrypt_password_hasher() 49 | correct_password = RawPassword("test_password") 50 | wrong_password = RawPassword("wrong_password") 51 | hashed: bytes = bcrypt_password_hasher.hash(correct_password) 52 | 53 | assert bcrypt_password_hasher.verify( 54 | raw_password=correct_password, 55 | hashed_password=hashed, 56 | ) 57 | assert not bcrypt_password_hasher.verify( 58 | raw_password=wrong_password, 59 | hashed_password=hashed, 60 | ) 61 | 62 | 63 | @pytest.mark.slow 64 | def test_bcrypt_password_hasher_with_long_password() -> None: 65 | bcrypt_password_hasher: BcryptPasswordHasher = get_bcrypt_password_hasher() 66 | long_password = RawPassword("a" * 100) # Exceeds bcrypt's 72-char limit 67 | hashed: bytes = bcrypt_password_hasher.hash(long_password) 68 | 69 | assert bcrypt_password_hasher.verify( 70 | raw_password=long_password, 71 | hashed_password=hashed, 72 | ) 73 | 74 | 75 | @pytest.mark.slow 76 | def test_bcrypt_password_hasher_with_special_characters() -> None: 77 | bcrypt_password_hasher: BcryptPasswordHasher = get_bcrypt_password_hasher() 78 | special_password = RawPassword("!@#$%^&*()_+{}|:<>?~`-=[]\\;',./№") 79 | hashed: bytes = bcrypt_password_hasher.hash(special_password) 80 | 81 | assert bcrypt_password_hasher.verify( 82 | raw_password=special_password, 83 | hashed_password=hashed, 84 | ) 85 | 86 | 87 | @pytest.mark.slow 88 | def test_bcrypt_password_hasher_with_different_pepper() -> None: 89 | hasher1 = BcryptPasswordHasher(PasswordPepper("Pepper1")) 90 | hasher2 = BcryptPasswordHasher(PasswordPepper("Pepper2")) 91 | password = RawPassword("test_password") 92 | hashed: bytes = hasher1.hash(password) 93 | 94 | assert hasher1.verify(raw_password=password, hashed_password=hashed) 95 | assert not hasher2.verify(raw_password=password, hashed_password=hashed) 96 | -------------------------------------------------------------------------------- /tests/unit/app/setup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/setup/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/setup/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-borovets/fastapi-clean-example/1ac393783c95c6dc3e77e52d02e9865ad672229c/tests/unit/app/setup/config/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/setup/config/conftest.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def password_settings_config_dict_valid(): 8 | return { 9 | "PEPPER": "Chili", 10 | } 11 | 12 | 13 | @pytest.fixture 14 | def auth_settings_config_dict_valid(): 15 | return { 16 | "JWT_SECRET": "test_secret", 17 | "JWT_ALGORITHM": "HS256", 18 | "SESSION_TTL_MIN": 2, 19 | "SESSION_REFRESH_THRESHOLD": 0.5, 20 | } 21 | 22 | 23 | @pytest.fixture 24 | def cookies_settings_config_dict_valid(): 25 | return { 26 | "SECURE": True, 27 | } 28 | 29 | 30 | @pytest.fixture 31 | def security_settings_config_dict_valid( 32 | password_settings_config_dict_valid, 33 | auth_settings_config_dict_valid, 34 | cookies_settings_config_dict_valid, 35 | ): 36 | return { 37 | "password": password_settings_config_dict_valid, 38 | "auth": auth_settings_config_dict_valid, 39 | "cookies": cookies_settings_config_dict_valid, 40 | } 41 | 42 | 43 | @pytest.fixture 44 | def postgres_settings_config_dict_valid(): 45 | return { 46 | "USER": "test_user", 47 | "PASSWORD": "test_password", 48 | "DB": "test_db", 49 | "HOST": "test_host", 50 | "PORT": 1234, 51 | "DRIVER": "asyncpg", 52 | } 53 | 54 | 55 | @pytest.fixture 56 | def sqla_engine_settings_config_dict_valid(): 57 | return { 58 | "ECHO": False, 59 | "ECHO_POOL": False, 60 | "POOL_SIZE": 10, 61 | "MAX_OVERFLOW": 10, 62 | } 63 | 64 | 65 | @pytest.fixture 66 | def logging_settings_config_dict_valid(): 67 | return { 68 | "LEVEL": "CRITICAL", 69 | } 70 | 71 | 72 | @pytest.fixture 73 | def app_settings_config_dict_valid( 74 | postgres_settings_config_dict_valid, 75 | sqla_engine_settings_config_dict_valid, 76 | security_settings_config_dict_valid, 77 | logging_settings_config_dict_valid, 78 | ): 79 | return { 80 | "postgres": postgres_settings_config_dict_valid, 81 | "sqla": sqla_engine_settings_config_dict_valid, 82 | "security": security_settings_config_dict_valid, 83 | "logs": logging_settings_config_dict_valid, 84 | } 85 | 86 | 87 | @pytest.fixture 88 | def app_settings_config_dict_invalid(app_settings_config_dict_valid): 89 | config = copy.deepcopy(app_settings_config_dict_valid) 90 | config.pop("sqla") 91 | return config 92 | -------------------------------------------------------------------------------- /tests/unit/app/setup/config/test_constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from app.setup.config.constants import BASE_DIR_PATH 4 | 5 | 6 | def test_base_dir() -> None: 7 | expected = Path(__file__).parent.parent.parent.parent.parent.parent 8 | 9 | assert expected == BASE_DIR_PATH 10 | -------------------------------------------------------------------------------- /tests/unit/app/setup/config/test_logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from app.setup.config.logs import configure_logging 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("level_str", "expected_level"), 11 | [ 12 | ("DEBUG", logging.DEBUG), 13 | ("INFO", logging.INFO), 14 | ("WARNING", logging.WARNING), 15 | ("ERROR", logging.ERROR), 16 | ("CRITICAL", logging.CRITICAL), 17 | ], 18 | ) 19 | def test_configure_logging_levels(level_str, expected_level): 20 | with patch("app.setup.config.logs.logging.basicConfig") as mock: 21 | configure_logging(level=level_str) 22 | 23 | assert mock.call_args[1]["level"] == expected_level 24 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import pytest 4 | 5 | from app.domain.entities.user import User 6 | from app.domain.enums.user_role import UserRole 7 | from app.domain.value_objects.user_id import UserId 8 | from app.domain.value_objects.user_password_hash import UserPasswordHash 9 | from app.domain.value_objects.username.username import Username 10 | 11 | 12 | @pytest.fixture 13 | def sample_user() -> User: 14 | user_id = UUID("12345678-1234-5678-1234-567812345678") 15 | username = "username" 16 | password_hash: bytes = "123456789abcdef0".encode() 17 | role = UserRole.USER 18 | is_active = True 19 | 20 | return User( 21 | id_=UserId(user_id), 22 | username=Username(username), 23 | password_hash=UserPasswordHash(password_hash), 24 | role=role, 25 | is_active=is_active, 26 | ) 27 | 28 | 29 | @pytest.fixture 30 | def other_sample_user() -> User: 31 | user_id = UUID("00000000-0000-0000-0000-000000000000") 32 | username = "username" 33 | password_hash: bytes = "123456789abcdef0".encode() 34 | role = UserRole.USER 35 | is_active = True 36 | 37 | return User( 38 | id_=UserId(user_id), 39 | username=Username(username), 40 | password_hash=UserPasswordHash(password_hash), 41 | role=role, 42 | is_active=is_active, 43 | ) 44 | --------------------------------------------------------------------------------