main.tsp,openapi/: контракт на TypeSpec и скомпилированные из него документы OpenAPI. Источник истины для всего остального.app/types/handlers/v1/,app/types/handlers/v2/: сгенерированное из OpenAPI — pydantic-модели схем и модели на операцию, по набору на версию. Руками не правится и линтером не проверяется.app/main.py: сборка приложения — роутеры, промежуточные слои, обработчики ошибок, страницы документации. Здесь жеlifespan: движок базы, миграции и сиды.app/contract.py: чтение документов контракта. Их же приложение отдаёт клиенту.app/settings.py: конфиг на pydantic-settings. Проверяется схемой на старте, поэтому приложение с плохим секретом не поднимается.app/db/: модели SQLModel, публичные проекции и сиды. Миграции Alembic лежат вmigrations/на корне, по соглашению самого alembic.app/glue.py: регистрация маршрутов по спеке — временная замена серверного плагина hey-api, см.TODO.md.app/routers/: обработчики, по файлу на сущность;app/routers/v2/— вторая версия. Каждый модуль отдаёт отображение «идентификатор операции → функция», аindex.pyсобирает их в набор версии. Пути, коды успеха и авторизация в обработчиках не пишутся: они приходят из контракта при регистрации.app/validators/,app/rules/,app/policies.py: правила предметной области, правила уровня базы, права доступа.app/lib/: пароли, метки версий, страницы, тела ошибок, разбор полей правки, границы идентификаторов, фабрики тестовых данных.scripts/:contract-test.sh,smoke-test.shи служебные скрипты для целей Makefile. Запускаются модулями (python -m scripts.routes), иначе корень проекта не попадает в пути импорта.tests/: спеки; бутстрап вtests/conftest.py.package.json: инструменты контракта — компилятор TypeSpec и генератор моделей. Оба npm-пакеты; приложению node не нужен.
make setup— зависимости обоих тулчейнов и.envиз шаблона.make dev/make start— запуск на http://localhost:8000.make test/make test-coverage— pytest, второй с порогом покрытия.make lint/make lint-fix— ruff и линт контракта.make generate-types— спека из контракта и модели из спеки.make generate-check— сгенерированное закоммичено и не разошлось.make contract-test— schemathesis по спеке.make migration-generate M="описание"/make migration-check.make smoke-test— собирает образ и проверяет его (нужен docker).make routes— таблица маршрутов с объявленной авторизацией.
- Порядок правки: новое поле, статус или операция сначала появляются
в
main.tsp, потомmake generate-types, потом код. Обратный порядок ловится в CI. - Формат и линт: ruff, длина строки 80. Дефолт инструмента и есть канон. Сгенерированное из линта исключено.
- Имена на проводе: поля контракта в camelCase, поля кода и колонки — в snake_case. Псевдонимы приходят из контракта вместе с моделями.
- Строка базы в модель контракта переводится через
to_schemaизapp/lib/serialization.py. Готовыйmodel_validate(row, from_attributes=True)для этого не годится: он ищет у объекта атрибут с именем псевдонима (fullName), не находит и оставляет поле пустым — молча, потому что поле необязательное. - Асинхронность до конца: обработчики, сессия базы и запросы — асинхронные. Счёт scrypt уходит в отдельный поток: иначе он держит событийный цикл десятки миллисекунд на каждый пароль.
Первая версия отдаётся с корня, вторая — с префиксом /v2. Первая
намеренно не уехала под /v1: адреса существующих клиентов переездом
сломались бы, а версия по определению нужна для того, чтобы этого не
делать.
Версии различаются полем phone у пользователя (@added(Versions.v2)):
во второй оно читается и принимается на запись, в первой его нет.
Удерживают это разные сгенерированные модели и разные проекции в
app/db/projections.py. Проекция — второй слой: ответ и так собирается
по модели из контракта, поэтому поле вне контракта в тело не попадёт, —
но проекция не даёт секрету покинуть базу, если у маршрута однажды не
окажется модели ответа. Курсы, уроки и токены между версиями не менялись
и переиспользуют те же роутеры.
Пути у версий одинаковые: префикс — это выбор развёртывания, а не часть
контракта. Адрес версии проставляется в servers при отдаче документа
(app/contract.py), иначе клиент, собранный по документу второй версии,
стучался бы в корень, то есть в первую.
make contract-test гоняет обе версии.
Python-генератор hey-api молодой: серверного плагина у него нет, SDK
печатает заглушки, а необязательное поле он не отличает от обнуляемого.
Что из-за этого написано руками и на что заменится — в TODO.md. Мы
исходим из того, что плагин выйдет: app/glue.py держит его свойства и
удаляется целиком, когда он появится. Правки — там же, в TODO.md.
- Фреймворк: pytest, режим asyncio автоматический.
- Транспорт: запросы идут через ASGI (
httpx.ASGITransport), без сетевого сервера. Сгенерированный клиент пока не годится, см.TODO.md. - Изоляция: приложение поднимается на каждый тест со своей базой в памяти. Обработчики меняют данные, и общая база делала бы результат зависимым от порядка прогона.
- Своя сессия: состояние базы тест смотрит сессией из фикстуры
session, а запись перечитывает черезrefetch— сессия помнит прочитанные объекты, и без этого тест видел бы состояние до запроса. - Покрытие: порог в цели
test-coverage, проверяется в CI. Он ниже фактического: гейт нужен как храповик против регресса, а не как повод подгонять цифры. - Контрактные тесты:
make contract-testгенерирует запросы из спеки — им найдены все три 500, которые здесь исправлены.
- Коммиты: conventional commits.
- PR: заголовок обязан быть conventional commit — по нему
release-please определяет разряд версии (проверяется в
pr-title.yml).
- Секреты: без
JWT_SECRETот 32 символов приложение не поднимается..envне попадает в репозиторий, а значение из.env.exampleгодится только для разработки: он лежит в публичном репозитории, в бой секрет приходит из окружения. - Пароли: scrypt из стандартной библиотеки, стоимость хранится внутри
дайджеста. Поэтому смена
SCRYPT_COSTне обесценивает выданные хеши, а тесты и контрактный прогон могут платить меньше боевой цены. - Тесты не читают
.env: чтение файла отключено приENVIRONMENT=test, переменные приходят изtests/conftest.py. - Сиды в бою не применяются: они тянут faker из группы dev, поэтому
импортируются только при
ENVIRONMENT != productionи только внутри условия. Это проверяетmake smoke-testна собранном образе. - Ограничитель частоты: свой, в
app/middleware.py. Готовая библиотека здесь не работает — общий для всех маршрутов ограничитель ищет обработчик по списку маршрутов приложения, а fastapi с некоторых версий хранит там ссылки на роутеры, а не сами операции. Ограничитель молча пропускал всё, и объявленный в контракте 429 не наступал никогда. - Ошибки фреймворка: обработчик объявлен на базовом классе исключения, а не на том, что предлагает FastAPI. Базовый фреймворк бросает сам — например, 400 на нечитаемом теле, — и обработчик, объявленный на наследнике, до такой ошибки не доходит.
- Границы идентификаторов: контракт объявляет
ResourceIdс верхней границей, и она же стоит в пути обработчика. Без неё значение проходит проверку, доезжает до базы и падает там на переполнении, то есть 500 наступает на корректном с виду запросе. - База: по умолчанию sqlite в памяти процесса, пересоздаётся при
каждом старте. Соединение одно (
StaticPool): у каждого соединения sqlite своя база в памяти, и без общего миграции работали бы с другой базой, чем приложение. Для постоянного хранения вDATABASE_URLзадаётся путь к файлу или адрес postgres.