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