Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

Я написал ASGI-фреймворк, в котором дерево папок — это и есть API

Дата публикации: 24-07-2026 13:20:26

Несколько месяцев пилил pet-проект, который зашёл дальше, чем планировал: файловый ASGI-фреймворк, где дерево папок — это и есть таблица роутов. Сегодня вышла версия 1.0 — с адверсариальным security-аудитом (нашёл у себя pickle RCE и CSWSH), 2329 тестами и нативным async на PostgreSQL. Рассказываю, что внутри и что было больнее всего сделать правильно. Узнать подробнее об EndoCore

Основное содержимое страницы с новостью.

Несколько месяцев я делал pet-проект, который зашёл дальше, чем планировалось: файловый ASGI-фреймворк на Python под названием EndoCore. Сегодня вышла версия 1.0.0, и это хороший повод рассказать, что это, зачем, и что было больнее всего сделать правильно.

Сразу ссылки, чтобы не листать до конца:

Проблема, которую я пытался решить

Любой растущий API-проект на декораторах рано или поздно расходится сам с собой: таблица роутов говорит одно, хендлеры — другое, а вопрос “к какой версии относится этот эндпоинт” превращается в археологию. С ростом становится только хуже — роуты живут в голове у того, кто их писал, разбросаны по файлам, которые импортируют друг друга в произвольном порядке.

Я захотел эту дрейфующую сущность просто убрать. Не “уменьшить риск”, а сделать так, чтобы дрейфовать было физически нечему.

Идея: дерево файлов = таблица роутов
Api/v1/User/[id]/Get.py    ->  GET  /v1/user/42        (id="42")
Api/v1/User/Role/Post.py   ->  POST /v1/user/role
Api/v2/User/[id]/Get.py    ->  GET  /v2/user/42        (v1 продолжает работать, нетронут)

Кладёшь файл в нужную папку — эндпоинт существует: заведён, версионирован, показывается в endo routes и /docs, без единой строчки регистрации. Удаляешь файл — эндпоинт исчезает. Никакого отдельного роутера, который может разойтись с тем, что реально делает код, потому что отдельного роутера просто нет — дерево читается напрямую.

# Api/v1/User/Role/Post.py  ->  POST /v1/user/role
from endocore import Request, Response

async def handler(request: Request) -> Response:
    data = await request.json()
    return Response.json({"created": data["name"]}, status=201)

Это уже полноценный рабочий эндпоинт. Не app = FastAPI(), не @app.post(...), никакого импорта, который надо было бы куда-то подключить. Путь и имя файла — это весь контракт.

Версионирование в этой модели становится тривиальным: v2 — это shutil.copytree с фильтром. v1 не шарит состояние роутера с v2 и не может быть задет его изменением. Никаких if version == 2 в хендлерах, никакого версионирования, которое работает только если все помнят конвенцию.

Что внутри, кроме роутинга

Один pip install, один процесс, ничего собирать руками:

  • ORM — SQLite и PostgreSQL, синхронный и асинхронный API, пул соединений, миграции с откатом. С 1.0 можно опционально включить нативный async на Postgres (async_native=True) — это не threadpool-обёртка над синхронным движком, а честный AsyncConnection из psycopg3, строго по желанию, поведение существующего деплоя не меняется при апгрейде.

  • Безопасность — только параметризованный SQL, идентификаторы валидируются и квотятся, пароли — через scrypt, подписанные сессии, CSRF, rate limiting.

  • Реалтайм — файловые WebSocket’ы (Socket.py) + pub/sub комнаты, которые можно разнести по воркерам через Redis fan-out.

  • DIDepends(...) в духе FastAPI, вложенный, кэшируется на запрос.

  • ТестированиеTestClient, добавленный специально для 1.0: внутрипроцессный ASGI-клиент без сетевого сокета и без лишней зависимости, драйвит и HTTP, и WebSocket-сессии.

  • Наблюдаемость — структурированное логирование с маскировкой секретов, Prometheus-метрики, OpenTelemetry-трейсинг, /openapi.json + Swagger UI.

  • Интеграции — Redis, Celery, SMTP — через extensions.py.

Обязательная зависимость всего одна — uvicorn. Резолвер, загрузчик, Request/Response, цепочка middleware, ORM и CLI — всё на стандартной библиотеке.

Небольшой пример ORM
from endocore.orm import Model, fields, configure, create_all, Q, F

class User(Model):
    name   = fields.CharField(max_length=100)
    age    = fields.IntegerField(default=0)
    active = fields.BooleanField(default=True)

configure(backend="sqlite", database="app.db")   # или backend="postgres", pool_size=10, ...
create_all(User)

User.objects.create(name="Ada", age=36)
User.objects.filter(age__gte=18).order_by("-age")             # ленивый QuerySet
User.objects.filter(Q(age__lt=18) | Q(name__icontains="a"))   # Q-объекты
User.objects.filter(age__gte=18).update(active=True)          # bulk update
User.objects.filter(pk=1).update(age=F("age") + 1)            # атомарный F()-expression

# неблокирующий вызов для ASGI-хендлеров:
user = await User.objects.aget(pk=1)

Каждое значение биндится через драйвер (никогда не форматируется строкой в SQL), каждый идентификатор валидируется и квотится, в SQL превращается только фиксированный whitelist лукапов, LIMIT/OFFSET принудительно приводятся к int. Это не опциональный слой — это единственный способ, которым ORM вообще умеет строить запрос.

Самая неприятная (и самая полезная) часть: адверсариальный security-аудит

В какой-то момент я перестал добавлять фичи и целый релиз (0.9.0b1) потратил на то, чтобы целенаправленно ломать собственный фреймворк — не читать код в поисках подозрительных мест, а воспроизводить эксплойт до фикса и снова после. Нашлось реально неприятное:

  • HTTP response splitting (CWE-113)Response не проверял заголовки/куки на сырые CR/LF/NUL.

  • Pickle RCE в Redis-кэше (CWE-502)RedisCache.get() вызывал pickle.loads() на произвольных байтах из Redis без аутентификации; всё, что могло записать этот ключ, получало RCE при следующем чтении. Теперь есть secret= для HMAC-подписи значений.

  • Cross-site WebSocket hijacking — хендшейк вообще не проверял Origin, так что страница с любого другого сайта могла открыть WebSocket к приложению и прокатиться на cookie-based сессии.

  • create_app() по умолчанию поднимался в dev=True — фабрика ASGI, которую документация рекомендует для продакшена (uvicorn endocore.asgi:create_app --factory), включала dev-режим по умолчанию, если переменная окружения не была явно выставлена — тихо открывая /docs, dev-watcher и ослабленную проверку origin.

  • Две гонки в ORM (get_or_create/update_or_create и M2M add()) роняли необработанный IntegrityError, когда два вызова конкурировали за одну ещё не существующую строку/связь.

Всё это описано в гайде по безопасности и CHANGELOG. bandit и pip-audit теперь гоняются в CI на каждый push, парсеры запросов (multipart, JSON, query string) property-fuzzed через hypothesis.

2329 тестов — и почему число само по себе не цель

После security-аудита я задался вопросом: а что из кода вообще ни разу не выполнялось хоть одним тестом? Пошёл по покрытию — не ради цифры, а потому что почти каждая непокрытая строка оказывалась либо реальным пробелом в поведении, либо забытым краевым случаем. По пути, просто как побочный эффект погони за покрытием (не целенаправленного поиска багов), нашлись три настоящих бага:

  • endo test -q -k name был сломанargparse.parse_known_args() разбивал распознанные и нераспознанные токены на два bucket’а, и при склеивании они теряли относительный порядок, так что -k оставался без значения.

  • Manager.ain_bulk() отсутствовал — у каждого другого асинхронного метода QuerySet был делегат на уровне Manager, у этого — нет.

  • Race condition в WebSocketManager.start() — метод возвращал управление сразу после запуска фонового потока-подписчика, не дожидаясь, пока Redis реально подтвердит psubscribe(). broadcast() от другого воркера сразу после start() (именно то, что происходит, когда несколько воркеров стартуют примерно одновременно) мог потеряться безвозвратно — Redis pub/sub не повторяет сообщения для опоздавших подписчиков.

Сейчас покрытие — 99.9%+, 2329 тестов, часть из них — против настоящих PostgreSQL и Redis в CI (не только SQLite и фейки). Условие для теста было простое, которое я себе поставил с самого начала: никаких тестов вида assert repr(User()) == "<User>" только ради цифры — каждый тест должен проверять реальное поведение, реальные коды ответов, реальные исключения.

Как это соотносится с FastAPI/Django

EndoCore

FastAPI

Django

Роутинг

путь файла = роут

декораторы

декораторы (urls.py)

Версионирование

папки vN, встроено

вручную

вручную (отдельные приложения)

ORM

встроена (sync + async)

нет (своя на выбор)

встроена (sync)

Миграции

встроены, с откатом

Alembic (отдельно)

встроены

Основные зависимости

1 (uvicorn)

Starlette + pydantic

нет (свой стек)

Размер кодовой базы

читается за вечер

большой

очень большой

Если вы уже писали на FastAPI — ментальная модель переносится почти без изменений: то же ASGI-развёртывание, похожая форма Request/Response, тот же паттерн Depends(...). Меняется только то, где живёт роут: вместо декоратора там, где кто-то его написал, POST /v1/user/role — это файл Api/v1/User/Role/Post.py.

Что дальше

1.0.0 фиксирует публичный API под semver — в документации есть отдельный раздел API stability, что именно покрыто гарантией, а что (внутренности async_native, точный SQL, шаблоны endo new) может меняться и дальше.

Это личный проект, не корпоративный продукт — делаю его в свободное время, потому что мне было интересно посмотреть, насколько далеко можно довести идею “дерево = API” без потери в безопасности и тестируемости. Буду рад вопросам, придиркам и issue — особенно к архитектурным решениям, которые я, возможно, оправдываю задним числом.

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Как желание написать простой CRUD привело к созданию целой видеоплатформы5722-06-2026
2AngaraBase: новая HTAP СУБД7829-06-2026
3Harness engineering: как за год собрать фабрику из десятка конвейеров05.8224-07-2026
4Организовал весь пентест-арсенал в одном месте: всё под рукой, офлайн и на русском5728-06-2026
5Форк файлового менеджера, подозрительно похожего на Midnight Commander07.5424-07-2026
6Query‑first подход или как из SQL запросов или MongoDB контрактов получить готовое REST API5706-07-2026
7Свой VPN на Rust: как я спорил с сетью, TLS и самим собой7827-06-2026
8Интеграция платёжных систем в high-risk вертикалях: архитектура, риски и практический опыт0707-07-2026
9Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов5807-07-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 9. Тональность: 0. Информативность: 13.37. Источник: habr.com.