fast-clean 1.2.2__py3-none-any.whl → 1.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1426 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: fast-clean
3
- Version: 1.2.2
4
- Summary: FastAPI Clean Architecture implementation
5
- Author-email: Luferov Victor <luferovvs@yandex.ru>, Orlov Artem <squakrazv@yandex.ru>, Kashapov Rustam <hardtechnik91@gmail.com>
6
- Requires-Python: >=3.13
7
- Description-Content-Type: text/markdown
8
- Requires-Dist: aiofiles>=24.1.0
9
- Requires-Dist: aiokafka>=0.12.0
10
- Requires-Dist: aioprometheus>=23.12.0
11
- Requires-Dist: alembic>=1.16.2
12
- Requires-Dist: cryptography>=44.0.1
13
- Requires-Dist: dishka>=1.6.0
14
- Requires-Dist: fastapi>=0.115.8
15
- Requires-Dist: fastapi-cache2[redis]>=0.2.2
16
- Requires-Dist: faststream>=0.5.34
17
- Requires-Dist: flatten-dict>=0.4.2
18
- Requires-Dist: miniopy-async>=1.21.1
19
- Requires-Dist: overrides>=7.7.0
20
- Requires-Dist: psycopg[binary]>=3.2.4
21
- Requires-Dist: pydantic>=2.10.6
22
- Requires-Dist: pydantic-settings>=2.8.0
23
- Requires-Dist: pyyaml>=6.0.2
24
- Requires-Dist: sentry-sdk>=2.32.0
25
- Requires-Dist: snakecase>=1.0.1
26
- Requires-Dist: sqlalchemy-utils>=0.41.2
27
- Requires-Dist: sqlalchemy[asyncio]>=2.0.38
28
- Requires-Dist: stringcase>=1.2.0
29
- Requires-Dist: typer>=0.15.1
30
-
31
- # FastClean
32
-
33
- FastAPI clean architecture implementation
34
-
35
-
36
- ## Contribution
37
-
38
- ```
39
- git clone git@github.com:Luferov/fast-clean.git
40
- uv sync
41
- uv run pre-commit install
42
- ```
43
-
44
- Python является высокоуровневым языком программирования с простой и понятной синтаксической структурой, что значительно ускоряет процесс разработки и облегчает поддержку кода. Его читаемость и лаконичность позволяют командам разработчиков быстро вводить новые функции и исправлять ошибки без потери качества. Это особенно важно при создании архитектуры сложных систем, где ясность и поддерживаемость кода имеют первостепенное значение.
45
- ## Инструментарий
46
- В качестве пакетных менеджеров используются два основных:
47
- 1. [uv](https://docs.astral.sh/uv/) - рекомендуемый
48
-
49
- Стандартный менеджер пакетных зависимостей [pip](https://pip.pypa.io/) используется в случаях, когда проект является точечным, "одноразовым" и не планирется к развитию после окончания разработки.
50
-
51
- Основной инструментарий для разработки бэкенд сервисов можно разделить на два условных раздела: для непосредственной работы приложения, а также для помощи в разработке. Прежде, чем приступать к разработке необходимо ознакомиться с основным инструментарием, который представлен ниже:
52
- 1. Основные инструменты для использования при реализации сервисов:
53
- - [FastAPI](https://fastapi.tiangolo.com/)
54
- - [Pydantic](https://docs.pydantic.dev/latest/)
55
- - [taskiq](https://taskiq-python.github.io)
56
- - [SqlAlchemy](https://www.sqlalchemy.org/)
57
- - [sqlmodel](https://sqlmodel.tiangolo.com)
58
- - [Alembic](https://alembic.sqlalchemy.org/en/latest/)
59
- - [Httpx](https://www.python-httpx.org/)
60
- - [Python-Jwt](https://pyjwt.readthedocs.io/en/stable/)
61
- - [Prefect](https://www.prefect.io/)
62
- - [FastStream](https://faststream.airt.ai/latest)
63
- - [uvicorn](https://www.uvicorn.org/)
64
- - [Dishka](https://dishka.readthedocs.io/en/stable/)
65
- - [apscheduler](https://github.com/agronholm/apscheduler)
66
- - [sentry](https://docs.sentry.io/platforms/python/)
67
- 2. Вспомогательные инструменты для разработки:
68
- - [pytest](https://docs.pytest.org/en/stable/)
69
- - [pytest-coverage](https://github.com/pytest-dev/pytest-cov)
70
- - [ruff](https://github.com/astral-sh/ruff)
71
- - [mypy](https://www.mypy-lang.org/)
72
- - [pre-commit](https://pre-commit.com/)
73
- - [black](https://black.readthedocs.io/en/stable/)
74
- - [ipykernel](https://ipython.readthedocs.io/en/)
75
-
76
- ## Типовая структура сервисов
77
-
78
- Ниже представлена типовая структура сервисов, а также описание дирректорий и файлов. Выход за пределы структуры позволяется только с разрешения техлида или лидера компетенций соответствующего направления, а также лидера разработки.
79
- ```
80
- 📁 {{ project name }} // Название проекта
81
- | 📁 .vscode // Конфигурации vscode
82
- | |- tasks.json // Файл с командами для расширения TaskExprorer
83
- | |- launch.json // Файл с командами для запуска сервиса с дебаггером
84
- | |- settings.json // Файл конфигурации редактора vscode
85
- | 📁 migrations // Файл с миграциями базы данных alembic
86
- | | 📁 versions
87
- | | | 2024_09_11_comment.py // Файл с миграцией базы данных
88
- | | __init__.py
89
- | | env.py // Настройки окружения для генерации и накатывания миграций
90
- | | script.py.mako // Шаблон для генерации миграций
91
- | | utils.py // Вспомогательные утилиты для генерации миграций
92
- | 📁 {{ service name }} // Название сервиса, например: portal, users, processing ...
93
- | | 📁 apps
94
- | | | 📁 healthcheck // Приложение для проверки healthcheck
95
- | | | | __init__.py // Испорты модуля healthcheck
96
- | | | | router.py // Роуты приложения healthcheck
97
- | | | | schemas.py // DTO схемы приложения healthcheck
98
- | | | 📁 users // Приложение пользователей
99
- | | | | 📁 repositories // Слой репозиториев приложения users
100
- | | | | | __init__.py // Экспорты репозитория пользователей
101
- | | | | | users.py // Репозиторий пользователей
102
- | | | | | company.py // Репозиторий компаний
103
- | | | | 📁 services // Сервисный слой приложения users
104
- | | | | | __init__.py // Экспорты сервисного слоя приложения users
105
- | | | | | users.py // Сервис пользователей
106
- | | | | | company.py // Сервис компаний
107
- | | | | 📁 use_cases // Слой UseCase (UserStory)
108
- | | | | | __init__.py // Экспорты UseCases
109
- | | | | | user_list.py // Получение списка пользователей
110
- | | | | | create_user.py // Создание пользователя
111
- | | | | | update_user.py // Обновление пользователя
112
- | | | | | delete_user.py // Удаление пользователя
113
- | | | | 📁 schemas // Схемы проекта
114
- | | | | | __init__.py // Экспорт схем приложения users
115
- | | | | | users.py // Схемы пользователей
116
- | | | | | company.py // Схемы компаний
117
- | | | | __init__.py
118
- | | | | container.py // Построение IoC контейнера
119
- | | | | enums.py // Константы перечислений
120
- | | | | models.py // Модели схемы базы данных приложения users
121
- | | | | serializers.py // Правила и механизм сериализации объектов
122
- | | | | router.py // Роутер и контроллеры
123
- | | | | exceptions.py // Исключения, наследуемые от core/exceptions
124
- | | | 📁 other... // Другие приложения в сервисе
125
- | | 📁 certs // Сертификаты для локальной разработки
126
- | | | 📁 elasticsearh // Сертификаты для elastic search
127
- | | | | ca.pem // Корневой сертификт
128
- | | | 📁 kafka // Серитфикаты для kafka
129
- | | | | ca.pem // Корневой сертификат
130
- | | | | cert.pem // Сертификат пользователя
131
- | | | | key.crt // Ключ
132
- | | 📁 entrypoints // Точки входа в приложение
133
- | | | | __init__.py
134
- | | | | rest.py // Запуска rest приложений
135
- | | | | grpc.py // Запуска grpc приложений
136
- | | | __init__.py
137
- | | | db.py // Настройки подключения к базе данных
138
- | | | depends.py // Построение IoC контейнера
139
- | | | enums.py // Список Enums
140
- | | | exceptions.py // Набор общих исключений
141
- | | | loggers.py // Настройки логгера
142
- | | | models.py // Миксины моделей схем баз данных
143
- | | | schemas.py // Общие схемы (DTO)
144
- | | | use_cases.py // Общие элементы UseCases
145
- | | 📁 tools // Cli утилиты
146
- | | | 📁 core // Модуль общих команд
147
- | | | | __init__.py // Модель общих команд
148
- | | | | cryptography.py // Команда cryptography
149
- | | | app.py // Точка входа в cli приложение
150
- | | | utils.py // Утилиты cli приложения
151
- | | __init__.py
152
- | | bootstrap.py // Файл сборки приложения
153
- | | exceptions.py // Файл с набором обработчиков исключений
154
- | | main.py // Точка входа в приложение
155
- | | middleware.py // Добавление сервисных прослоек
156
- | | router.py // root роутер приложения
157
- | | settings.py // Файл с набором конфигураций приложения
158
- | | swagger.py // Файл для доп настрйоки свагера
159
- | 📁 seed // Пресеты данных для заполнения в СУБД
160
- | | 001.users.json // Заготовка пользователей
161
- | | 002.groups.json // Группы пользователей
162
- | 📁 tests // Unit тесты сервиса
163
- | | 📁 apps // Набор приложений
164
- | | | 📁 healthcheck // Тесты для приложения healthcheck
165
- | | | | conftest.py // Фикстуры, относящиеся к приложению healthcheck
166
- | | | | test_router.py // Тесты роута
167
- | | | | test_repositories.py // Тесты слоя репозиториев
168
- | | | | test_services.py // Тесты сервисного слоя
169
- | | conftest.py // Файл, содержащий фикстуры всего проекта
170
- | .dockerignore // Игнорирование при сборке докер образов
171
- | .env.example // Пример файла .env
172
- | .gitignore // Игнорирование git
173
- | .isort.cfg // Параметры сортировки импортов
174
- | .logging.dev.yaml // Параметры логгирования при локальной разработки
175
- | .logging.yaml // Параметры логгирования в прод окружении
176
- | .pre-commit-config.yaml // Конфигурация прекоммит файлов
177
- | .python-version // Версия python, для pyenv
178
- | .alembic.ini // Конфигцрационный файл для alembic
179
- | .docker-compose.yaml // Файл инфраструктуры для изолированной разработки
180
- | Dockerfile // Файл описания Docker контейнера
181
- | entrypoint.sh // Docker entrypoint
182
- | Makefile // Файл вспомогательных команд
183
- | manage.py // Точка входа для cli утилит
184
- | poetry.lock // Устанавливаемые зависимости и их версии
185
- | pyproject.toml // Файл конфигурации проекта
186
- | pytest.ini // Конфигурация unit-тестов
187
- | README.md // Описание проекта и предметной области
188
- ```
189
- ### Файлы проекта
190
-
191
- ```
192
- 📁 .devcontainer // Файлы для организации разработки в контейнера
193
- 📁 .vscode // Настройки редактора vscode
194
- 📁 migrations // Миграции alembic для sqlalchemy
195
- 📁 seed // Загрузки начальных данных, словарей
196
- 📁 src | {{ service name }} // Файлы со структурой проекта
197
- 📁 tests // Тесты проекта
198
- .dockerignore // Файл для игнорирования при копировании файлов внутрь образов
199
- .env.example // Пример файла для
200
- .gitignore // Игнорирование файлов в VCS
201
- .pre-commit-config.yaml // Конфигурация прекоммита
202
- .python-version // Указание версии python для пакетных менеджеров
203
- alembic.ini // Конфигурация для исполнения миграций
204
- docker-compose.yaml // Файл поднятия инфраструктуры
205
- Dockerfile // Файл сборки докер образа
206
- entrypoint.sh // Точка входа в приложение
207
- Makefile // Список вспомогательных команд
208
- manage.py // Точка входа в CLI команды
209
- pyproject.toml // Конфигурация зависимостей и проекта
210
- README.md // Описание проекта
211
- ```
212
-
213
- ## Модели (_Models_)
214
- **Модель БД** — это структурированное описание данных, хранящихся в СУБД. Она определяет, какие таблицы существуют, какие у них поля (колонки), типы данных этих полей, связи между таблицами, ограничения (constraints), индексы и т. д. Модель может включать в себя следующие сущности:
215
- - Таблицы - основные структуры хранения данных;
216
- - Столбцы - поля в таблицах с различными типами данных, например, VARCHAR, INTEGER, BOOLEAN, TIMESTAMP и другие;
217
- - Первичный ключ - уникальные идентификаторы записей, должны однозначно определять запись в таблицы и быть минимальными;
218
- - Внешние ключ - реляционные связи между таблицами;
219
- - Индексы - набор полей, служащий для ускорения поиска и фильтрации (замедляют вставку);
220
- - Представления, триггеры, функции и схемы - дополнительные элементы модели БД.
221
- **ORM (Object-Relational Mapping)** — это технология, которая позволяет программисту работать с базой данных **через обычные объекты и классы**, а не через SQL-запросы. Необходимо также помнить об объектно-реляционном разрыве:
222
-
223
- | В ООП | В реляционной БД |
224
- | ---------------------------- | ------------------------------ |
225
- | Классы и объекты | Таблицы и строки |
226
- | Наследование классов | Нет прямого аналога |
227
- | Связи через атрибуты/объекты | Связи через foreign keys |
228
- | Инкапсуляция | Данные открыты и плоские |
229
- | Иерархии | Требуют JOIN’ов и нормализации |
230
- В качестве primary key могут использоваться следующие ключи:
231
- - **BigInt** - AutoIncrement;
232
- - **UUID** - только uuid версии 7.
233
- Пример таблицы пользователей с использованием [SqlAlchemy](https://www.sqlalchemy.org) представлен ниже.
234
-
235
- ```python
236
- # apps/users/models.py
237
-
238
- import uuid
239
- from datetime import datetime
240
- import sqlalchemy as sa
241
- from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String
242
- from sqlalchemy.orm import Mapped, mapped_column
243
- from sqlalchemy.sql import func
244
- from sqlalchemy_utils.types import UUIDType
245
- from va_core.db import Base
246
- from va_core.models import TimestampMixin
247
-
248
-
249
- class User(Base):
250
- """
251
- Модель пользователя.
252
- """
253
-
254
- __tablename__ = 'users'
255
-
256
- id: Mapped[uuid.UUID] = mapped_column(UUIDType(binary=False), primary_key=True, default=uuid.uuid7, server_default=func.gen_random_uuid())
257
- username: Mapped[str] = mapped_column(String(length=100), unique=True, index=True, nullable=False)
258
- email: Mapped[str] = mapped_column(String(length=320), unique=True, index=True, nullable=False)
259
- hashed_password: Mapped[str] = mapped_column(String(length=1024), nullable=True)
260
- first_name: Mapped[str] = mapped_column(String(length=100), nullable=False)
261
- second_name: Mapped[str | None] = mapped_column(String(length=100), nullable=True)
262
- last_name: Mapped[str] = mapped_column(String(length=100), nullable=False)
263
- avatar: Mapped[str | None] = mapped_column(String(250), nullable=True)
264
- is_active: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.false(), nullable=False)
265
- is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.false(), nullable=False)
266
- is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.false(), nullable=False)
267
- login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
268
-
269
- def __repr__(self: Self) -> str:
270
- return f'User(id={self.id!r}, email={self.email!r}, username={self.username!r})'
271
- ```
272
-
273
- После описания файла с моделями в каком-то из доменов, необходимо создать миграцию БД с помощью [alembic](https://alembic.sqlalchemy.org/en/latest/).
274
- Из файла конфигурации `alembic.ini` нас интересуют пять полей:
275
- ```ini
276
- [alembic]
277
- # Дирректория, в которой будут находиться миграции
278
- script_location = migrations
279
- # Шаблон файла для генерации новой миграции
280
- # Например: 2024_09_11_initial.py
281
- file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(slug)s
282
-
283
- # Добавление в sys.path дирректории
284
- prepend_sys_path = .
285
-
286
- # Разделить файлового пути / или \. По умолчанию тот, который в текущей операционной системе
287
- version_path_separator = os
288
-
289
- # Строка подключения к СУБД, в частности, postgres
290
- # async_fallback - попробует использовать asyncpg, если не получится - перейдет автоматически на psycopg2 (блокирующий синхронный драйвер)
291
- # options=-csearch_path= - задание дефолтной схемы для соединения, по умолчанию public
292
- sqlalchemy.url = %(DB_PROVIDER)s://%(DB_USER)s:%(DB_PASSWORD)s@%(DB_HOST)s:%(DB_PORT)s/%(DB_NAME)s?async_fallback=True&options=-csearch_path=%(DB_SCHEMA)s
293
- ```
294
- Для работы с SqlAlchemy есть замечательная библиотека [SqlAlchemy-utils](https://sqlalchemy-utils.readthedocs.io/en/latest/). Однако для корректной работы этой библиотеке, нужно помочь `alembic` генерировать типы из [SqlAlchemy-utils](https://sqlalchemy-utils.readthedocs.io/en/latest/):
295
- ```python
296
- import sqlalchemy_utils
297
-
298
- def render_item(type_, obj, autogen_context):
299
- """
300
- Apply custom rendering for selected items.
301
- """
302
- if type_ == 'type' and isinstance(obj, sqlalchemy_utils.types.ChoiceType):
303
- autogen_context.imports.add('import sqlalchemy_utils')
304
- autogen_context.imports.add(f'from {obj.choices.__module__} import {obj.choices.__name__}') # type: ignore
305
- return f'sqlalchemy_utils.types.ChoiceType({obj.choices.__name__}, impl=sa.{obj.impl.__class__.__name__}())' # type: ignore
306
-
307
- if type_ == 'type' and isinstance(obj, sqlalchemy_utils.types.JSONType):
308
- autogen_context.imports.add('import sqlalchemy_utils')
309
- return 'sqlalchemy_utils.types.JSONType()'
310
-
311
- if type_ == 'type' and isinstance(obj, sqlalchemy_utils.types.UUIDType):
312
- autogen_context.imports.add('import sqlalchemy_utils')
313
- return f'sqlalchemy_utils.types.UUIDType(binary={obj.binary})'
314
-
315
- # Default rendering for other objects
316
- return False
317
- ```
318
- По сути, эти строчки помогают `alembic` точнее генерировать миграцию для объектов, взятых из `sqlalchemy_utils`. Однако, нужно указать эту функцию в файле `migrations/env.py`:
319
- ```python
320
- from importlib import import_module
321
- from logging.config import fileConfig
322
- from typing import TYPE_CHECKING
323
-
324
- from alembic import context
325
- from sqlalchemy import engine_from_config, pool
326
- from {{ project_name }}.settings import settings
327
- from fast_clean.db import Base
328
- # Для статического анализатора
329
- if TYPE_CHECKING:
330
- from .utils import render_item
331
- else:
332
- from migrations.utils import render_item
333
-
334
- # Импорт моделей
335
- import_module('{{ project_name }}.apps.users.models')
336
-
337
- # Доступ к значениям из файла конфигурации alembic.ini
338
- config = context.config
339
- section = config.config_ini_section
340
- # Установка параметров конфигурации, которые потом используются в строке подключения
341
- config.set_section_option(section, 'DB_PROVIDER', settings.db.provider)
342
- config.set_section_option(section, 'DB_USER', settings.db.user)
343
- config.set_section_option(section, 'DB_PASSWORD', settings.db.password)
344
- config.set_section_option(section, 'DB_HOST', settings.db.host)
345
- config.set_section_option(section, 'DB_PORT', str(settings.db.port))
346
- config.set_section_option(section, 'DB_NAME', settings.db.name)
347
- config.set_section_option(section, 'DB_SCHEMA', settings.db.scheme)
348
-
349
- # Interpret the config file for Python logging.
350
- # This line sets up loggers basically.
351
- if config.config_file_name is not None:
352
- fileConfig(config.config_file_name)
353
-
354
- # add your model's MetaData object here
355
- # for 'autogenerate' support
356
- # from myapp import mymodel
357
- # target_metadata = mymodel.Base.metadata
358
- target_metadata = Base.metadata
359
-
360
-
361
- def run_migrations_offline() -> None:
362
- """
363
- Run migrations in 'offline' mode.
364
-
365
- This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available.
366
- Calls to context.execute() here emit the given string to the script output.
367
- """
368
- url = config.get_main_option('sqlalchemy.url')
369
- context.configure(
370
- url=url,
371
- target_metadata=target_metadata,
372
- literal_binds=True,
373
- compare_server_default=True,
374
- dialect_opts={'paramstyle': 'named'},
375
- )
376
-
377
- with context.begin_transaction():
378
- context.run_migrations()
379
-
380
- def run_migrations_online() -> None:
381
- """
382
- Run migrations in 'online' mode.
383
-
384
- In this scenario we need to create an Engine and associate a connection with the context.
385
- """
386
- connectable = engine_from_config(
387
- config.get_section(config.config_ini_section, {}),
388
- prefix='sqlalchemy.',
389
- poolclass=pool.NullPool,
390
- )
391
- with connectable.connect() as connection:
392
- context.configure(
393
- connection=connection,
394
- target_metadata=target_metadata,
395
- render_item=render_item, # добавляем
396
- )
397
- with context.begin_transaction():
398
- context.run_migrations()
399
-
400
- if context.is_offline_mode():
401
- run_migrations_offline()
402
- else:
403
- run_migrations_online()
404
- ```
405
-
406
- Миграция генерируется вызовом сдедующей команды:
407
- ```bash
408
- uv run alembic revision --autogenerate -m "$(NAME)" # $(NAME) - название миграции
409
- ```
410
- Накатывается миграция вызовом следующей команды:
411
- ```python
412
- uv run alembic upgrade head
413
- ```
414
- Другие команды можно посмотреть на официальном сайте `alembic`.
415
-
416
- ## Схемы (_Schemas_)
417
- Для реализации базовых [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) операция для каждой модели необходимо написать как минимум три дополнительных [pydantic](https://docs.pydantic.dev/latest/) схемы ([DTO](https://en.wikipedia.org/wiki/Data_transfer_object)):
418
- - **ReadSchema** - схема для чтения данных;
419
- - **CreateSchema** - схема, которая содержит необходимые поля для создания записи в БД;
420
- - **UpdateSchema** - схема для обновления полей в БД.
421
-
422
- Схемы для работы с пользователем представлены ниже:
423
- ```python
424
- # {{ project_name }}/apps/users/schemas/users.py
425
-
426
- import uuid
427
- from datetime import datetime
428
- from pydantic import ConfigDict, EmailStr, Field
429
-
430
- # Базовые схемы, содержащие общий контракт
431
- from fast_clean.schemas import (
432
- CreateSchema,
433
- ReadSchema,
434
- UpdateSchema,
435
- )
436
-
437
-
438
- class UserCreateSchema(CreateSchema):
439
- """
440
- Схема для создания пользователя.
441
- """
442
-
443
- username: str
444
- email: EmailStr
445
- hashed_password: str | None
446
- first_name: str
447
- second_name: str | None
448
- last_name: str
449
- avatar: str | None
450
- is_active: bool
451
- is_superuser: bool
452
-
453
-
454
- class UserReadSchema(ReadSchema):
455
- """
456
- Схема для чтения пользователя.
457
- """
458
-
459
- model_config = ConfigDict(from_attributes=True)
460
-
461
- username: str
462
- email: EmailStr
463
- hashed_password: str | None
464
- first_name: str
465
- second_name: str | None
466
- last_name: str
467
- avatar: str | None
468
- is_superuser: bool
469
- is_active: bool
470
- is_deleted: bool
471
- login_at: datetime | None
472
- created_at: datetime
473
- updated_at: datetime
474
-
475
-
476
- class UserUpdateSchema(UpdateSchema):
477
- """
478
- Схема для обновления пользователя.
479
- """
480
-
481
- username: str | None = None
482
- email: EmailStr | None = None
483
- hashed_password: str | None = None
484
- first_name: str | None = None
485
- second_name: str | None = None
486
- last_name: str | None = None
487
- avatar: str | None = None
488
- is_superuser: bool | None = None
489
- is_active: bool | None = None
490
- is_deleted: bool | None = None
491
- login_at: datetime | None = None
492
- ```
493
-
494
- Правилом хорошего тона будет сделать реэкспорт из `{{ project_name }}/apps/users/schemas/__init__.py`, чтобы в файле `users.py` не прописывать `__all__ = ('UserCreateSchema' , 'UserReadSchema', 'UserUpdateSchema',)`:
495
- ```python
496
- # {{ project_name }}/apps/users/schemas/__init__.py
497
-
498
- from .users import UserCreateSchema as UserCreateSchema
499
- from .users import UserReadSchema as UserReadSchema
500
- from .users import UserUpdateSchema as UserUpdateSchema
501
- ```
502
- ## Перечисления (_Enums_)
503
- Типы перечислений записываются в соответствующих файлах `enums.py` в каждом приложении. В конце названия типа должны быть суфикс `Enum`:
504
- ```python
505
- """
506
- Модуль, содержащий перечисления приложения auth.
507
- """
508
- from enum import StrEnum, auto
509
-
510
- class PasswordResetStatusEnum(StrEnum):
511
- """
512
- Статус сброс пароля.
513
- """
514
-
515
- ACTIVE = auto()
516
- APPLIED = auto()
517
- REJECTED = auto()
518
- ```
519
- Использовать `ENUM` в PostgreSQL **не рекомендуется в большинстве продакшен-сценариев** по следующим причинам:
520
- - Добавить новое значение в `ENUM` можно только **через команду `ALTER TYPE`**, это **DDL-операция**, которая:
521
- - блокирует доступ к типу во время выполнения;
522
- - не может быть выполнена внутри транзакции (`BEGIN ... COMMIT`) до PostgreSQL 12.
523
- - Если вы используете миграции (например, Alembic), изменение `ENUM` требует **ручного вмешательства** или нестандартных паттернов.
524
- - Трудно откатить миграцию или обновить enum-состояние без "костылей".
525
- - Если вы экспортируете/импортируете данные или работаете с несколькими СУБД — `ENUM` не всегда переносится корректно.
526
- - В коде (например, на Python с SQLAlchemy) `ENUM` иногда ведёт себя непредсказуемо при сериализации/десериализации, особенно если значения изменяются.
527
- - Нельзя переименовать существующие значения без костылей.
528
- - Нельзя удалить значение.
529
- - Нельзя изменить порядок.
530
- - `ENUM`- значения не поддаются "мягкому" контролю бизнес-логики (например, нельзя легко сделать "архивным").
531
- ## Репозитории (_Repositories_)
532
- **Репозиторий** — это интерфейс, описывающий набор операций для работы с сущностями предметной области, который отделяет бизнес-логику от инфраструктурных деталей хранения данных.
533
- #### Репозиторий СУБД
534
- Выше описана модель `User`, которая в базу данных проецируется как таблица `users`.
535
- В библотеке [fast_clean](https://github.com/Luferov/fast-clean) описан базовый [CRUDRepository](https://github.com/Luferov/fast-clean/blob/main/fast_clean/repositories/crud/db.py), которые содержит общие методы для всех моделей. Каждый репозиторий, который отражает таблицу в СУБД должен быть отнаследован от базового репозитория `DbCrudRepository`:
536
- ```python
537
- class DbCrudRepository(
538
- DbCrudRepositoryBase[ModelType, ReadSchemaType, CreateSchemaType, UpdateSchemaType, uuid.UUID],
539
- Generic[ModelType, ReadSchemaType, CreateSchemaType, UpdateSchemaType],
540
- ):
541
- """
542
- Репозиторий для выполнения CRUD операций над моделями нового типа в базе данных.
543
- """
544
-
545
- __abstract__ = True
546
- ```
547
-
548
- Класс для репозитория сервиса пользователей будет с учетом описанных выше схем DTO будет выглядеть следующим образом:
549
- ```python
550
- class UserRepository(
551
- DbCrudRepository[User, UserReadSchema, UserCreateSchema, UserUpdateSchema]
552
- ):
553
-
554
- async def exists_with_username(self: Self, username: str) -> bool:
555
- """
556
- Проверяем существует ли пользователь с заданным username.
557
- """
558
- async with self.session_manager.get_session() as s:
559
- statement = sa.exists().where(self.model_type.username == username).select()
560
- return bool((await s.execute(statement)).scalar())
561
- ```
562
-
563
- В данном случае, класс вместе со стандартными методами из класса `DbCrudRepository` содержит также метод проверки существования пользователя по `username`. Список методов из DbCrudRepository следующий:
564
- - `async def get(self: Self, id: IdType) -> ReadSchemaBaseType` - получение записи по идентификатору;
565
- - `async def get_or_none(self: Self, id: IdType) -> ReadSchemaBaseType | None: ` - получение записи, если запись не найдена `None`;
566
- - `async def get_by_ids(self: Self, ids: Sequence[IdType], *, exact: bool = False) -> list[ReadSchemaBaseType]:` - получение записей по списку идентификаторов;
567
- - `async def get_all(self: Self) -> list[ReadSchemaBaseType]:` - получение всех записей (_использовать осторожно_);
568
- - реализация _offset_ пагинации
569
- ```python
570
- async def paginate(
571
- self: Self,
572
- pagination: PaginationSchema,
573
- user: Any,
574
- policies: list[str],
575
- *,
576
- search: str | None = None,
577
- search_by: Iterable[str] | None = None,
578
- sorting: Iterable[str] | None = None,
579
- ) -> PaginationResultSchema[ReadSchemaBaseType]:
580
- ```
581
- - `async def create(self: Self, create_object: CreateSchemaBaseType) -> ReadSchemaBaseType:` - создание записи;
582
- - `async def bulk_create(self: Self, create_objects: list[CreateSchemaBaseType]) -> list[ReadSchemaBaseType]:` - массовое создание записей;
583
- - `async def update(self: Self, update_object: UpdateSchemaBaseType) -> ReadSchemaBaseType:` - обновление записи в БД;
584
- - `async def bulk_update(self: Self, update_objects: list[UpdateSchemaBaseType]) -> None:` - массовое обновление записей в БД по единой транзакцией;
585
- - `async def upsert(self: Self, create_object: CreateSchemaBaseType) -> ReadSchemaBaseType:` - попытка обновить, в случае неуспеха запись создается;
586
- - `async def delete(self: Self, ids: Sequence[IdType]) -> None:` - удаление записи;
587
- - `def select(cls) -> sa.Select[tuple[ModelBaseType]]:` - выбор базовой модели и всех полей наследника, если такие есть (_classmethod_).
588
- #### Остальные репозитории
589
- Репозитории, которые отвечают за взаимодействие системы с внешним миром могут работать в двух режимах:
590
- 1. [C персистентным соединением соединением](https://ru.wikipedia.org/wiki/Постоянное_HTTP-соединение) - устанавливать соединение и держать его до завершения всех операций соединения.
591
- ```plantuml
592
- @startuml
593
- autonumber
594
-
595
- participant Client
596
- participant Server
597
-
598
-
599
- Client -> Client: Открываем соединения
600
- activate Client
601
-
602
- Client -> Server: Запрос
603
- activate Server
604
- Server -> Client: Ответ
605
- Client -> Server: Запрос
606
- Server -> Client: Ответ
607
- Client -> Server: Запрос
608
- Server -> Client: Ответ
609
- destroy Server
610
-
611
- Client -> Client: Закрываем соединение
612
- deactivate Client
613
- @enduml
614
- ```
615
- 2. С постоянным разрывом соединение
616
- ```plantuml
617
- @startuml
618
- autonumber
619
-
620
- participant Client
621
- participant Server
622
-
623
-
624
- == 1 действие ==
625
- Client -> Client: Открываем соединения
626
- activate Client
627
- Client -> Server: Запрос
628
- activate Server
629
- Server -> Client: Ответ
630
- deactivate Server
631
- Client -> Client: Закрываем соединение
632
- destroy Client
633
-
634
- == 2 действие ==
635
- Client -> Client: Открываем соединения
636
- activate Client
637
- Client -> Server: Запрос
638
- activate Server
639
- Server -> Client: Ответ
640
- deactivate Server
641
- Client -> Client: Закрываем соединение
642
- destroy Client
643
- @enduml
644
- ```
645
- Очевидно при любой возможности необходимо использовать первый вариант. В таком случае, репозиторий должен представлять из себя контекстный менеджер, внутри которого будет обеспечиваться все взаимодействие. Пример представлен ниже:
646
- ```python
647
- """
648
- Репозиторий по отправке файлов в VoiceAI.
649
- """
650
- import uuid
651
- from dataclasses import dataclass
652
- from contextlib import AbstractAsyncContextManager
653
- from typing import Any, Self
654
-
655
- import httpx
656
-
657
- from ..schemas import (
658
- UserCreateSchema,
659
- UserResultScheme,
660
- UserRepositoryParams,
661
- UserCreateSchema,
662
- )
663
-
664
- @dataclass
665
- class UserRepository(AbstractAsyncContextManager):
666
- """
667
- Репозиторий взаимодействия с пользователями через http соединение.
668
- """
669
- self.params = params
670
- self.client: httpx.AsyncClient | None = None
671
-
672
- async def __aenter__(self: Self) -> Self:
673
- if self.client is None:
674
- self.client = self.make_client()
675
- return self
676
-
677
- async def __aexit__(
678
- self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any | None
679
- ) -> None:
680
- if self.client:
681
- await self.client.aclose()
682
- self.client = None
683
- return None
684
-
685
- def make_client(self: Self, retries: int = 3, verify: bool = False) -> httpx.AsyncClient:
686
- """
687
- Создаем экземпляр класса соединения.
688
- """
689
- transport = httpx.AsyncHTTPTransport(retries=retries, verify=verify)
690
- return httpx.AsyncClient(transport=transport)
691
-
692
- async def create_user(self: Self, user: UserCreateSchema) -> UserResultScheme:
693
- """
694
- Создаем user запись.
695
- """
696
- assert self.client
697
- response = await self.client.post(self.params.destination, json=user.model_dump(by_alias=True))
698
- response.raise_for_status()
699
- return UserResultScheme.model_validate(response.json())
700
-
701
- async def delete(self: Self, user_id: uuid.UUID) -> None:
702
- """
703
- Удаляем пользователя.
704
- """
705
- assert self.client
706
- await self.client.delete(f'{self.params.destination}/users/{user_id}')
707
-
708
- ```
709
- При такой реализации использование будет выглядеть следующим образом:
710
- ```python
711
-
712
- async with user_repository:
713
- # Внутри keep-alive соединения
714
- result = await user_repository.create_user(user)
715
- await user_repository.delete(result.id)
716
-
717
- ```
718
- ## Сервисы (_Services_)
719
- **Сервисный слой** — это архитектурное ядро, где живет _чистая бизнес-логика_ приложения. Сервисы инкапсулируют ключевые правила и процессы: валидацию данных, управление транзакциями, координацию между источниками информации, интеграцию с внешними системами и выполнение сложных вычислительных задач.
720
- **Его главные достоинства — тестируемость и универсальность.** Сервисы легко проверять изолированно, а их независимость от деталей ввода/вывода (HTTP, UI) и конкретных пользовательских сценариев (_User Story_) позволяет гибко переиспользовать их в различных _UseCases_.
721
-
722
- В качестве примера рассмотрим UserService, в котором реализована функция для создания пользователя.
723
- ```python
724
- from dataclasses import dataclass
725
-
726
-
727
- @dataclass
728
- class UserService:
729
- user_repository: UserRepository
730
- password_service: PasswordService
731
-
732
- async def create(self: Self, create_schema: UserCreateRequestSchema) -> UserReadSchema:
733
- """
734
- Создаем пользователя.
735
- """
736
- if await self.user_repository.exists_with_username(create_schema.username):
737
- raise ModelAlreadyExistsError('username', 'Пользователь с таким логином уже существует')
738
- if await self.user_repository.exists_with_email(create_schema.email):
739
- raise ModelAlreadyExistsError('email', 'Пользователь с таким email уже существует')
740
- register_dict = create_schema.model_dump()
741
- password = register_dict.pop('password')
742
- register_dict['hashed_password'] = None
743
- register_dict['is_active'] = False
744
- if settings.allow_set_password and password is not None:
745
- if not await self.password_service.check_strength(create_schema.password):
746
- raise WeakPasswordError(self.password_service.MIN_PASSWORD_LENGTH)
747
- register_dict['hashed_password'] = await run_in_processpoll(self.password_service.hash, password)
748
- register_dict['is_active'] = True
749
- register_dict['avatar'] = None
750
- register_dict['is_superuser'] = False
751
- user_create_schema = UserCreateSchema.model_validate(register_dict)
752
- return await self.user_repository.create(user_create_schema)
753
- ```
754
-
755
- Как видно, сервис выполняет следующие действия обеспечивая создание пользователя:
756
- - проверку на существования пользователя с _username_;
757
- - проверка на существование пользователя с _email_;
758
- - проверяет адекватность пароля и хеширует его с помощью _PasswordService_;
759
- - заполняет поля по умолчанию, затем создает пользователя и возвращает пользователя выше.
760
- Как видно из примера, в сервисе реализована валидация, а уже само создание реализуется с помощью _UserRepository_.
761
- ### Использование внешней библиотеки
762
- Все внешние библиотеки в проекте должны иметь единую точку входа. Это означает, что в проекте должен существовать лишь один файл, где библиотека импортируется и все использование должно идти через этот модуль или класс.
763
- Для примера, возьмём библиотеку _python-jose_, которая имплементируют в себе две функции `encode` и `decode`:
764
- ```python
765
- from jose import jwt
766
- token = jwt.encode({'key': 'value'}, 'secret', algorithm='HS256')
767
- # token == u'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSJ9.FG-8UppwHaFp1LgRYQQeS6EDQF7_6-bMFegNucHjmWg'
768
- jwt.decode(token, 'secret', algorithms=['HS256'])
769
- # {u'key': u'value'}
770
- ```
771
- Казалось бы, API библиотеки достаточно простое и почему бы сразу не использовать эти вызовы в необходимых местах. Однако, у нас возникает ситуация, при которой замена этой библиотеки на что-то другое приведет к необходимости поиска импортов по всему проекту, а также в тестах и велика вероятность что-то пропустить. Чтобы этого избежать, необходимо сделать "обертку" над этой библиотекой, поместив вызов этих функциой в методы сервисного класса:
772
- ```python
773
- from typing import Self
774
- from jose import jwt # единственное место импорта из библиотеки jwt
775
- from dataclasses import dataclass
776
- from {{ project_name }} import Settings
777
-
778
- @dataclass
779
- class CryptoService:
780
- settings: Settings
781
-
782
- def encode(self: Self, value: dict, *, algorithm: str = 'HS256') -> str:
783
- return jwt.encode(value, self.settings.secret_key, algorithm=algorithm)
784
-
785
- def decode(self: Self, token: str, *, algorithm: str = 'HS256') -> dict:
786
- return jwt.decode(token, self.settings.secret_key, algorithms=[algorithm])
787
- ```
788
- Использование во всех сервисах _CryptoService_ вместо импорта библиотеки позволит в случае изменения библиотеки поменять имплементацию только в одном месте. Кроме этого, такое использование окажет положительное влияние на процесс тестирования.
789
- ## Варианты использования (_UseCases_)
790
-
791
- В отличие от сервисного слоя, который группирует логику по доменным областям. **_UseCase_** — это следующий шаг декомпозиции, фокусирующийся на **одном конкретном бизнес-сценарии**, **полностью изолируя чистую бизнес-логику** от инфраструктуры. Это делает приложение ещё более гибким, тестируемым и понятным, особенно в сложных доменах. UseCase не всегда заменяет сервисный слой, но он поднимает уровень абстракции бизнес-правил на новую ступень.
792
- Для примера, реализуем сервис регистрации пользователей:
793
-
794
- ```python
795
- from dataclasses import dataclass
796
-
797
- @dataclass
798
- class UserRegisterUseCase:
799
- user_service: UserService
800
- me_service: MeService
801
- notify_service: NotifyService
802
-
803
- async def __call__(self: Self, user_schema: UserCreateRequestSchema) -> UserResponseSchema:
804
- """
805
- Регистрация пользователя.
806
- """
807
- current_session = await self.me_service.get_session()
808
- if current_session:
809
- raise LogoutRequiredError()
810
- user = await self.user.service.create(user_schema)
811
- await self.notify_service.notify_register(user)
812
- return UserResponseSchema.model_validate(user, from_attributes=True)
813
- ```
814
- Создание пользователя (его валидация) это реализация сервисного действия, отсылка уведомления это не часть сервиса по созданию пользователя, это часть пользовательской истории регистрации.
815
- ## Роуты и контроллеры
816
- Роутеры организуют группу связанных маршрутов. Они нужны для разделения кода по функциональности, упрощение масштабирования больших приложений, повторное использование префиксов, тегов и зависимостей. Также они определяют внешний контракт взаимодействия по API. Пример роута для FastAPI:
817
- ```python
818
- from fastapi import APIRouter
819
-
820
- router = APIRouter(
821
- prefix='/users', # Автоматический префикс для всех входящих маршрутов
822
- tags=['Users'], # Группировка в документации Swagger
823
- )
824
- ```
825
- Функция, которая используется с этим роутером является контроллером:
826
- ```python
827
-
828
- @router.get('')
829
- async def get_all_users() -> list[UserResponseSchema]:
830
- """Получаем всех пользователей."""
831
- ...
832
- ```
833
- Функция `get_all_users` является контроллером в привычном понимании этого слова, в виду того, что она может быть записана и так:
834
- ```python
835
-
836
- async def get_all_users() -> list[UserResponseSchema]:
837
- """Получаем всех пользователей."""
838
- ...
839
-
840
- @router.get('')(get_all_users)
841
- # ^ роутер
842
- # ^ контроллер
843
- ```
844
- Связка роута и контроллера представляет внешний контаркт API по которому будет происходить общение с внешними системами, по сути должна содержать следующее:
845
- - путь обращения;
846
- - _UseCase_, который отражает пользовательскую _UserStory_;
847
- - параметры запроса (Path, QueryParams, RequestSchema, Headers, etc);
848
- - параметры ответа.
849
-
850
- Рассмотрим пример для регистрации пользователя:
851
- ```python
852
-
853
- from dishka.integrations.fastapi import FromDishka, inject
854
-
855
- @router.post('')
856
- @inject
857
- async def register(
858
- register_schema: UserCreateRequestSchema, # Параметры запроса
859
- user_register_use_case: FromDishka[UserRegisterUseCase] # UseCase
860
- ) -> MeResponseSchema: # параметры ответа
861
- """
862
- Регистрация пользователя.
863
- """
864
- user = await user_register_use_case(register_schema)
865
- return MeResponseSchema.model_validate(user, from_attributes=True)
866
- ```
867
-
868
- ## Исключения (_Exceptions_)
869
- Классы ошибок можно разделить на три части:
870
- - Слой приложения - http ошибки со статус кодами:
871
- - 4XX - ошибки клиента;
872
- - 5XX - ошибки сервера;
873
- - Слой доменной логики - ошибки бизнес логики;
874
- - Слой внешних обращений - внешние ошибки, возникающие при обращении к внешним системам.
875
- Наименование также является важной частью, в виду того, что для обозначений классов ошибок можно использовать суффиксы _**Exception**_ и _**Error**_:
876
- - Exception — непредвиденные ошибки, которые возникают в результате работы программы при неконтролируемых действиях;
877
- - Error — ошибки бизнес-логики, пользовательские ошибки.
878
- > PEP8 говорит:
879
- > Because exceptions should be classes, the class naming convention applies here. However, you should use the suffix “Error” on your exception names (if the exception actually is an error)
880
-
881
- Линтер Ruff транслирует необходимость использования вместо `class Validation(Exception)` использовать `class ValidationError(Exception)`.
882
-
883
- В центре нашего приложения должен находиться главный и единственный класс ошибки, например:
884
- ```python
885
- class BusinessLogicException(Exception, ABC):
886
- """
887
- Базовое исключение бизнес-логики.
888
- """
889
-
890
- @property
891
- def type(self: Self) -> str:
892
- """
893
- Тип ошибки.
894
- """
895
- return snakecase(type(self).__name__.replace('Error', ''))
896
-
897
- @property
898
- @abstractmethod
899
- def msg(self: Self) -> str:
900
- """
901
- Сообщение ошибки.
902
- """
903
- ...
904
-
905
- def __str__(self: Self) -> str:
906
- return self.msg
907
-
908
- def get_schema(self: Self, debug: bool) -> BusinessLogicExceptionSchema:
909
- """
910
- Получаем схему исключения.
911
- """
912
- return BusinessLogicExceptionSchema(
913
- type=self.type,
914
- msg=self.msg,
915
- traceback=(''.join(traceback.format_exception(type(self), self, self.__traceback__)) if debug else None),
916
- )
917
- ```
918
- Его название может быть любым, как `BusinessLogicException`, так и `DomainError`. Базовый класс должен содержать **контракт** для всех ошибок, возвращаемых по API. Ошибки второго уровня должны быть связаны с ошибками статус кодов, например:
919
- ```python
920
-
921
- class PermissionDeniedError(BusinessLogicException):
922
- """
923
- Ошибка, возникающая при недостатке прав для выполнения действия.
924
- """
925
-
926
- @property
927
- def msg(self: Self) -> str:
928
- return 'Недостаточно прав для выполнения действия'
929
-
930
- class ModelNotFoundError(BusinessLogicException):
931
- """
932
- Ошибка, возникающая при невозможности найти модель.
933
- """
934
-
935
- def __init__(
936
- self,
937
- model: type[ModelType] | str,
938
- *args: object,
939
- model_id: int | uuid.UUID | Iterable[int | uuid.UUID] | None = None,
940
- model_name: str | Iterable[str] | None = None,
941
- message: str | None = None,
942
- ) -> None:
943
- super().__init__(*args)
944
- self.model = model
945
- self.model_id = model_id
946
- self.model_name = model_name
947
- self.custom_message = message
948
-
949
- @property
950
- def msg(self: Self) -> str:
951
- if self.custom_message is not None:
952
- return self.custom_message
953
- msg = f'Не удалось найти модель {self.model if isinstance(self.model, str) else self.model.__name__}'
954
- if self.model_id is not None:
955
- if isinstance(self.model_id, Iterable):
956
- return f'{msg} по идентификаторам: [{", ".join(map(str, self.model_id))}]'
957
- return f'{msg} по идентификатору: {self.model_id}'
958
- if self.model_name is not None:
959
- if isinstance(self.model_name, Iterable):
960
- return f'{msg} по именам: [{", ".join(self.model_name)}]'
961
- return f'{msg} по имени: {self.model_name}'
962
- return msg
963
-
964
-
965
- class ModelAlreadyExistsError(BusinessLogicException):
966
- """
967
- Ошибка, возникающая при попытке создать модель с существующим уникальным полем.
968
- """
969
-
970
- def __init__(self, field: str, message: str, *args: object) -> None:
971
- super().__init__(*args)
972
- self.field = field
973
- self.message = message
974
-
975
- @property
976
- def msg(self: Self) -> str:
977
- return self.message
978
-
979
- def get_schema(self: Self, debug: bool) -> BusinessLogicExceptionSchema:
980
- return ModelAlreadyExistsErrorSchema.model_validate(
981
- {**super().get_schema(debug).model_dump(), 'field': self.field}
982
- )
983
-
984
- ```
985
-
986
- Чтобы связать эти ошибки с http статусами ошибок необходимо использовать функционал [FastAPI exception handlers](https://fastapi.tiangolo.com/tutorial/handling-errors/):
987
- ```python
988
- async def business_logic_exception_handler(
989
- settings: CoreSettingsSchema, request: Request, exception: BusinessLogicException
990
- ) -> Response:
991
- """
992
- Обработчик базового исключения бизнес-логики.
993
- """
994
- return await http_exception_handler(
995
- request,
996
- HTTPException(
997
- status_code=status.HTTP_400_BAD_REQUEST,
998
- detail=[exception.get_schema(settings.debug).model_dump()],
999
- ),
1000
- )
1001
-
1002
- async def permission_denied_error_handler(
1003
- settings: CoreSettingsSchema, request: Request, error: PermissionDeniedError
1004
- ) -> Response:
1005
- """
1006
- Обработчик ошибки, возникающей при недостатке прав для выполнения действия.
1007
- """
1008
- return await http_exception_handler(
1009
- request,
1010
- HTTPException(
1011
- status_code=status.HTTP_403_FORBIDDEN,
1012
- detail=[error.get_schema(settings.debug).model_dump()],
1013
- ),
1014
- )
1015
-
1016
-
1017
- async def model_not_found_error_handler(
1018
- settings: CoreSettingsSchema, request: Request, error: ModelNotFoundError
1019
- ) -> Response:
1020
- """
1021
- Обработчик ошибки, возникающей при невозможности найти модель.
1022
- """
1023
- return await http_exception_handler(
1024
- request,
1025
- HTTPException(
1026
- status_code=status.HTTP_404_NOT_FOUND,
1027
- detail=[error.get_schema(settings.debug).model_dump()],
1028
- ),
1029
- )
1030
-
1031
-
1032
- async def model_already_exists_error_handler(
1033
- settings: CoreSettingsSchema, request: Request, error: ModelAlreadyExistsError
1034
- ) -> Response:
1035
- """
1036
- Обработчик ошибки, возникающей при попытке создать модель с существующим уникальным
1037
- полем.
1038
- """
1039
- return await http_exception_handler(
1040
- request,
1041
- HTTPException(
1042
- status_code=status.HTTP_409_CONFLICT,
1043
- detail=[error.get_schema(settings.debug).model_dump()],
1044
- ),
1045
- )
1046
-
1047
-
1048
- def use_exceptions_handlers(app: FastAPI, settings: CoreSettingsSchema) -> None:
1049
- """
1050
- Регистрируем глобальные обработчики исключений.
1051
- """
1052
- app.exception_handler(BusinessLogicException)(partial(business_logic_exception_handler, settings))
1053
- app.exception_handler(PermissionDeniedError)(partial(permission_denied_error_handler, settings))
1054
- app.exception_handler(ModelNotFoundError)(partial(model_not_found_error_handler, settings))
1055
- app.exception_handler(ModelAlreadyExistsError)(partial(model_already_exists_error_handler, settings))
1056
- ```
1057
- Бизнес ошибки должны наследоваться от ошибок, которые связаны с HTTP кодами, чтобы при бросании ошибок пользовательских они автоматически трансформировались в HTTP ошибки и отдавались клиенту.
1058
- Дерево ошибок выглядит как показано ниже:
1059
- ```plantuml
1060
- @startmindmap
1061
- * BusinessLogicException \n(все ошибки должны наследоваться отсюда)
1062
- ** PermissionDeniedError \n(ошибка привязанная к 403 статусу)
1063
- ** ModelNotFoundError \n(ошибка привязання к 404 статусу)
1064
- *** UserNotFoundError \n(бизнес ошибка)
1065
- *** CompanyNotFoundError \n(бизнес ошибка)
1066
- @endmindmap
1067
- ```
1068
-
1069
- Кроме этого, если вы однозначно знаете какие ошибки могут возникнуть при обработке данных, рекомендуется писать их явно для генерации документации в _Swagger_ на основе _OpenAPI_ 3.0.
1070
- ```python
1071
-
1072
- @router.post(
1073
- '',
1074
- responses={
1075
- 403: {'description': 'Доступ запрещен'},
1076
- 404: {'description': 'Не найдено'},
1077
- 418: {'description': '# I\'m a teapot'}
1078
- }
1079
- )
1080
- ```
1081
- ## Dependency Injection
1082
-
1083
- Dependency Injection (DI, **внедрение зависимостей**) — это паттерн проектирования, который делает код более гибким, тестируемым и поддерживаемым. Вот ключевые причины его использования:
1084
- 1. Уменьшение связности (Coupling);
1085
- 2. Упрощение тестирования;
1086
- 3. Гибкость и расширяемость;
1087
- 4. Читаемость и прозрачность;
1088
- 5. Соблюдение правил [SOLID](https://ru.wikipedia.org/wiki/SOLID_(программирование).
1089
- Хотелось бы DI & IoC-контейнер, который:
1090
- - Представляет собой реестр (контейнер) объектов, которыми он управляет
1091
- - Позволяет декларативно конфигурировать объекты и их свойства
1092
- - Код классов не должен зависеть от IoC-фреймворка
1093
- - Берёт на себя:
1094
-      - Управление жизненным циклом объектов: создание, удаление
1095
-      - Управление зависимостями между объектами.
1096
- В качестве DI фреймворка выбран [Dishka](https://dishka.readthedocs.io/en/stable/). Эта библиотека предоставляет контейнер IoC, который действительно полезен. Если вы устали от бесконечной передачи объектов только для того, чтобы создать другие объекты, только для того, чтобы эти объекты создавали еще больше - вы не одиноки, и у нас есть решение. Не для каждого проекта требуется контейнер IoC, но посмотрите, что мы предлагаем.
1097
- IoC-контейнер - это специальный объект (или фреймворк, предоставляющий такой объект), который предоставляет необходимые объекты в соответствии с правилами внедрения зависимостей и управляет их сроком службы. DI-framework - это другое название для таких фреймворков.
1098
- Прежде, чем читать дальше крайне рекомендуется прочитать документацию DI фреймворка [Dishka](https://dishka.readthedocs.io/en/stable/).
1099
-
1100
- Давайте опишем провайдер для регистрации пользователя:
1101
- ```python
1102
- from dishka import Provider as DishkaProvider
1103
- from dishka import Scope, provide
1104
-
1105
-
1106
- class Provider(DishkaProvider):
1107
- scope = Scope.REQUEST
1108
-
1109
- user_repository = provide(UserRepository)
1110
- user_service = provide(UserService)
1111
-
1112
- # use a function
1113
- @provide
1114
- @staticmethod
1115
- def get_me_service(user_repository: UserRepository) -> MeService:
1116
- return MeService(user_repository)
1117
-
1118
- user_register_use_case = provide(UserRegisterUseCase)
1119
-
1120
- provider = Provider() # Объявляем провайдер
1121
- ```
1122
- После этого создаем IoC контейнер и запускаем FastAPI приложение:
1123
- ```python
1124
- import os
1125
- from collections.abc import AsyncGenerator
1126
- from contextlib import asynccontextmanager
1127
- from importlib import import_module
1128
- from pathlib import Path
1129
- import tochka_jsonrpcapi as jsonrpc
1130
- from fastapi import FastAPI
1131
- from fast_clean.container import ContainerManager
1132
- from fast_clean.contrib.healthcheck.router import router as healthcheck_router
1133
-
1134
- @asynccontextmanager
1135
- async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
1136
- yield
1137
- await app.state.dishka_container.close()
1138
-
1139
- app = FastAPI(lifespan=lifespan)
1140
- ContainerManager.init_for_fastapi(app)
1141
- app.router.route_class = DishkaRoute
1142
-
1143
- app.middleware('http')(context_middleware)
1144
- app.include_router(healthcheck_router)
1145
- ```
1146
- ## Слои приложения
1147
-
1148
- Если рассматривать слоистость приложения, то его можно представить следующим образом:
1149
- ```plantuml
1150
- @startuml
1151
- component Application {
1152
- component Router {
1153
- component Controller {
1154
- component UseCase {
1155
- component "Service 1" {
1156
- component "Repository 1"
1157
- component "Service 3" {
1158
- component "Repository 3"
1159
- }
1160
- component "Service 2" {
1161
- component "Repository 2"
1162
- }
1163
- }
1164
- component "Service 2" {
1165
- component "Repository 2"
1166
- }
1167
- component "Service 4" {
1168
- component "Repository 4"
1169
- }
1170
- }
1171
- }
1172
- }
1173
- }
1174
- @enduml
1175
- ```
1176
- Кроме этого, возможность зон обращений можно представить следующей диаграммой:
1177
- ```plantuml
1178
- @startmindmap
1179
- * Application
1180
- ** Router
1181
- *** Controller
1182
- **** UseCase
1183
- ***** Service 1
1184
- ****** Service 2
1185
- ******* Repository 3
1186
- ****** Repository 1
1187
- ****** Repository 2
1188
- ***** Serivce 2
1189
- ****** Repository 3
1190
- @endmindmap
1191
- ```
1192
- ## Sync и Async функции
1193
-
1194
- В современных приложениях на Python, особенно при использовании асинхронного программирования с asyncio, часто возникает необходимость выполнять синхронные (блокирующие) функции в асинхронной среде. Чтобы избежать блокировки основного асинхронного потока выполнения, синхронные функции можно запускать в отдельном пуле потоков (_thread pool_) или процессов (_process pool_).
1195
-
1196
- _ThreadPoolExecutor_ подходит для выполнения блокирующих I/O операций, то есть когда функция ожидает ввода-вывода, например:
1197
- - Сетевые запросы к _API_ без асинхронных библиотек.
1198
- - Чтение и запись файлов на диск.
1199
- - Обращение к базам данных без поддержки асинхронности.
1200
-
1201
- Причины использовать _ThreadPoolExecutor_ для _I/O-bound_ задач:
1202
- - _GIL (Global Interpreter Lock):_ В Python интерпретатор CPython имеет GIL, который не позволяет нескольким потокам выполнять байт-код одновременно. Однако при I/O операциях потоки освобождают GIL, позволяя другим потокам продолжить выполнение.
1203
- - _Низкие накладные расходы:_ Создание и переключение между потоками дешевле, чем между процессами.
1204
- - _Простота обмена данными:_ Потоки разделяют общую память, что упрощает передачу данных между ними.
1205
-
1206
- В связи с этим синхронные функции не оборачиваем в async. Оборачивание синхронных операций в `async` добавляет некоторые накладные расходы на формирование `Future`, которая по сути ничего не ожидает.
1207
-
1208
- ```python
1209
- # ✅ correct
1210
- def get_todo_repositories() -> ResultSchema:
1211
- return do_smt_sync()
1212
-
1213
- # ❌ incorrect
1214
- async def get_todo_repositories() -> ResultSchema:
1215
- return do_smt_sync()
1216
- ```
1217
-
1218
- Синхронные функции в асинхронной среде запускаются в соответствующих функциях для запуска `run_in_processpool` и`run_in_threadpool`:
1219
- ```python
1220
- # run_in_processpool.py
1221
-
1222
- import asyncio
1223
- import multiprocessing as mp
1224
- from concurrent.futures import ProcessPoolExecutor
1225
- from functools import partial
1226
- from typing import Callable, TypeVar
1227
-
1228
- from typing_extensions import ParamSpec
1229
-
1230
- P = ParamSpec('P')
1231
- R = TypeVar('R')
1232
-
1233
- async def run_in_processpool(fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
1234
- """
1235
- Запуск функции в отдельном процессе.
1236
- Используем fork в связи с https://github.com/python/cpython/issues/94765.
1237
- """
1238
- kwargs_fn = partial(fn, **kwargs)
1239
- loop = asyncio.get_running_loop()
1240
- with ProcessPoolExecutor(mp_context=mp.get_context('fork')) as executor:
1241
- return await loop.run_in_executor(executor, kwargs_fn, *args)
1242
- ```
1243
-
1244
- ```python
1245
- # run_in_threadpool.py
1246
-
1247
- import asyncio
1248
- import multiprocessing as mp
1249
- from concurrent.futures import ProcessPoolExecutor
1250
- from functools import partial
1251
- from typing import Callable, TypeVar
1252
-
1253
- from typing_extensions import ParamSpec
1254
-
1255
- P = ParamSpec('P')
1256
- R = TypeVar('R')
1257
-
1258
-
1259
- process_pool: ProcessPoolExecutor | None = None
1260
-
1261
-
1262
- async def run_in_processpool(fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
1263
- """
1264
- Запуск функции в отдельном процессе.
1265
-
1266
- Используем fork в связи с https://github.com/python/cpython/issues/94765.
1267
- """
1268
- global process_pool
1269
- if process_pool is None:
1270
- process_pool = ProcessPoolExecutor(mp_context=mp.get_context('fork'))
1271
- kwargs_fn = partial(fn, *args, **kwargs)
1272
- loop = asyncio.get_running_loop()
1273
- return await loop.run_in_executor(process_pool, kwargs_fn)
1274
- ```
1275
-
1276
- ## FastClean Core
1277
-
1278
- В основном все приложения одинаковые и различаются лишь бизнес фукнционалом. Системный функционал и тулинг выноситься в библиотеку, которая обычно называется core. В нашем представлении ядерная библиотека представляется набором системных утилит и приложений и представлена в репозитории [FastClean](https://github.com/Luferov/fast-clean).
1279
- ## API Request и Response схемы
1280
-
1281
- Все API схемы для описания входящих запросов должны быть описаны с помощью `Pydantic` и должны быть отнаследованы от `RequestSchema`, которая показана ниже. Однако, исходящие запросы должны приводиться к нотации `camelCase`, описываться с помощью `Pydantic` моделей и наследоваться от `ResponseSchema`.
1282
- ```python
1283
-
1284
- from __future__ import annotations
1285
-
1286
- import uuid
1287
- from typing import Generic, Self, TypeVar
1288
-
1289
- from pydantic import AliasGenerator, BaseModel, ConfigDict, field_validator
1290
- from pydantic.alias_generators import to_camel
1291
-
1292
- class RequestSchema(BaseModel):
1293
- """
1294
- Схема запроса.
1295
- """
1296
- # Настройка добавления алиасов и валидирования из camelCase -> snake_case
1297
- model_config = ConfigDict(
1298
- alias_generator=AliasGenerator(
1299
- validation_alias=to_camel,
1300
- )
1301
- )
1302
-
1303
- class ResponseSchema(BaseModel):
1304
- """
1305
- Схема ответа.
1306
- """
1307
-
1308
- # Настройка генерирования в camelCase из snake_case -> camelCase
1309
- model_config = ConfigDict(
1310
- alias_generator=AliasGenerator(
1311
- serialization_alias=to_camel,
1312
- )
1313
- )
1314
-
1315
- RemoteResponseSchema = RequestSchema
1316
- RemoteRequestSchema = ResponseSchema
1317
-
1318
- ```
1319
-
1320
- Межсервисное взаимодействие должно обеспечиваться с помощью этих же трансформаций, в следствие чего должны использоваться классы `RemoteResponseSchema` и `RemoteRequestSchema`.
1321
- ## Транзакции
1322
- **Транзакция** — это единица работы с базой данных, состоящая из одного или нескольких операций _SQL_, которые выполняются как одно целое. Транзакции обладают свойствами, известными как _ACID_:
1323
-
1324
- - **Atomicity (Атомарность):** Все операции внутри транзакции выполняются полностью или не выполняются вовсе. Если происходит ошибка, все изменения откатываются.
1325
- - **Consistency (Согласованность):** Транзакции переводят базу данных из одного согласованного состояния в другое, соблюдая все ограничения целостности.
1326
- - **Isolation (Изоляция):** Одновременные транзакции не влияют друг на друга, обеспечивая изолированность выполнения.
1327
- - **Durability (Долговечность):** После фиксации транзакции ее результаты сохраняются даже в случае сбоев системы.
1328
-
1329
- Типичный код репозитория к базе данных на любой запрос открывает транзакцию. В случае, если транзакция явно не открывается - она все равно открывается внутренностями _SqlAlchemy_.
1330
-
1331
- ```python
1332
- import uuid
1333
- import sqlalchemy as sa
1334
- from typing import Protocol, Self
1335
-
1336
- from fast_clean.depends import SessionManagerProtocol
1337
-
1338
- from ..models import check_list_mark_template
1339
-
1340
-
1341
- class UserRepository:
1342
- """
1343
- Реализация репозитория для работы со списком выбранного todo листа.
1344
- """
1345
-
1346
- def __init__(self, session_manager: SessionManagerProtocol) -> None:
1347
- self.session_manager = session_manager
1348
-
1349
- async def set_active(self: Self, todo_id: uuid.UUID, status: bool) -> None:
1350
- """
1351
- Устанавливаем шаблоны оценки чек-листа.
1352
- """
1353
- async with self.session_manager.get_session() as s:
1354
- statement = (
1355
- sa.update(self.model_type)
1356
- .where(self.model_type.id == pk)
1357
- .values({'status': status})
1358
- .returning(self.model_type)
1359
- )
1360
- await s.execute(statement)
1361
- # Мы все еще находимся в транзакции и она открыта.
1362
- ```
1363
- В виду того, что мы находимся в транзации в момент, когда сессия в _SqlAlchemy_ завершается, поэтому над стандартной сессии пишется обертка, которая в случае, если мы находимся внутри транзакции, создается вложенная транзакция (или возвращает ее же), которая открывается на основе, т.е. по сути вызывается `SAVEPOINT` внутри транзакции. Код сервиса представлен ниже:
1364
-
1365
- ```python
1366
- from contextlib import asynccontextmanager
1367
- from typing import AsyncContextManager, AsyncIterator, Protocol, Self
1368
-
1369
- import sqlalchemy as sa
1370
- from sqlalchemy.ext.asyncio import AsyncSession
1371
-
1372
-
1373
- class TransactionService:
1374
- """
1375
- Реализация сервиса транзакций.
1376
- """
1377
-
1378
- def __init__(self, session: AsyncSession) -> None:
1379
- self.session = session
1380
-
1381
- @asynccontextmanager
1382
- async def begin(self: Self, immediate: bool = True) -> AsyncIterator[None]:
1383
- """
1384
- Начинаем транзакцию.
1385
- """
1386
- async with self.session.begin():
1387
- if immediate:
1388
- await self.session.execute(sa.text('SET CONSTRAINTS ALL IMMEDIATE'))
1389
- yield
1390
- ```
1391
- Если несколько репозиториев нужно объединить в одну транзакцию, то стартовать транзакцию необходимо в в сервисе транзакций:
1392
- ```python
1393
-
1394
-
1395
- class TodoUseCase(UseCase[ResponseModel]):
1396
-
1397
- def __init__(
1398
- self,
1399
- transaction_service: TransactionService,
1400
- service1: Service1,
1401
- service2: Service2
1402
- ) -> None:
1403
- self.transaction_service = transaction_service
1404
- self.service1 = Service1Protocol
1405
- self.service2 = Service2Protocol
1406
-
1407
- def __call__(self) -> ResponseModel:
1408
- async with self.transaction_service.begin():
1409
- # Выполнения запросов service1 и service2 происходит внутри одной транзакции
1410
- await service1.do_smt()
1411
- await service2.do_smt()
1412
- ```
1413
- ## Запуск приложения в режиме разработки
1414
-
1415
- ```bash
1416
- git clone {{ repository }}
1417
- cp .env.example .env
1418
-
1419
- # Запускаем приложение
1420
- uv run uvicorn {{ service name }}.entrypoints.main:app
1421
- ```
1422
-
1423
- ## Литература
1424
-
1425
- - [Лучшие практики FastAPI](https://github.com/zhanymkanov/fastapi-best-practices)
1426
- - [Чистая архитектура](https://www.litres.ru/book/robert-s-martin/chistaya-arhitektura-iskusstvo-razrabotki-programmnogo-obe-39113892/)