Перейти к содержанию

Роутинг

Роутер — сердце EndoCore. Здесь нет таблицы маршрутов, которую вы пишете и поддерживаете руками — дерево папок под Api/ и есть таблица маршрутов. На этой странице правило разобрано полностью, включая все граничные случаи, которые резолвер реально реализует (сверено с исходниками endocore/core/discovery.py, endocore/core/router.py и endocore/core/registry.py — это не «примерно как это работает», а именно как это работает).

Ментальная модель

При старте EndoCore делает один обход файловой системы по Api/ (rglob("*.py"), значит рекурсивно заходит во все подпапки). Для каждого найденного .py-файла он смотрит на имя файла и его путь и решает одну из трёх вещей:

  1. Это endpoint — имя файла совпадает с HTTP-методом, файл лежит внутри папки версии, и он не находится внутри папки, которая исключена из роутинга. Из него делается маршрут.
  2. Файл пропущен с причиной — имя похоже на маршрут (совпадает с методом), но что-то не так с его расположением (нет папки версии, файл внутри Services/). Это видно в endo check и в логе загрузки.
  3. Это обычный код — имя файла вообще не является HTTP-методом (validator.py, create_role.py, __init__.py, ...). EndoCore вообще не рассматривает его при роутинге; это просто модуль Python, который можно импортировать как обычно. Этот случай нигде не логируется, потому что это не ошибка — это подавляющее большинство файлов в реальном приложении (не endpoint'ы).

Ничего не регистрируется, не декорируется и не перечисляется в конфиге. Существование файла по этому пути с этим именем и есть вся регистрация маршрута.

Правило, точно

Файловая система URL
Api/v1/User/Role/Get.py GET /v1/user/role
Api/v1/User/Role/Post.py POST /v1/user/role
Api/v1/User/[id]/Get.py GET /v1/user/42id = "42"
Api/v2/User/Role/Post.py POST /v2/user/role

Разберём по сегментам:

1. Первый сегмент пути обязан быть версией

Первая папка прямо под Api/ должна совпадать с регэкспом ^v\d+$ (например v1, v2, v17) — строчная v, за которой идёт одна или больше цифр, и больше ничего. Проверка идёт через re.match, который регистрозависим: папка V1 не совпадёт, и все файлы под ней будут пропущены с причиной «not under a version folder (vN)» (это видно в выводе endo check — частая опечатка новичков).

Допуска для v1.2 или v1-beta тоже нет — сегмент версии строго «v» + цифры. Что версия значит семантически (это не просто префикс URL — v1 и v2 — независимые копии всего, что под ними), см. Версионирование.

2. Каждая папка между версией и файлом — сегмент URL

Каждая промежуточная папка становится одним сегментом пути, в нижнем регистре:

Api/v1/User/Role/Get.py
        ^^^^ ^^^^
        User Role   ->  сегменты: "user", "role"  ->  /v1/user/role

Так что User, user и USER на диске превращаются в один и тот же сегмент URL user — регистр на диске — чисто стилистический выбор (PascalCase-папки приятно смотрятся в дереве файлов IDE; в URL всегда нижний регистр). Это значит, что две папки, отличающиеся только регистром (Api/v1/User/Get.py и Api/v1/user/Get.py), конфликтуют: обе дают идентичный URL /v1/user, так что тот файл, который обход файловой системы посетит вторым, молча перезапишет обработчик первого для этого метода (порядок вставки детерминирован — файлы обходятся в отсортированном по пути порядке — но какой из них окажется вторым, на глаз не очевидно). Хорошая новость: именно этот класс ошибок ловится endo check — он выведет [dup] GET /v1/user defined more than once. Запускайте его после любого переименования.

3. Имя файла (его стем, не расширение) — это HTTP-метод

Get.py, Post.py, Put.py, Patch.py, Delete.py, Head.py, Options.py — стем переводится в верхний регистр и сверяется ровно с этим набором: {GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS}. get.py, GET.py, Get.py эквивалентны (регистр имени файла не важен, в отличие от имён папок, где регистр имеет значение только для читаемости точно так же).

Файл, чей стем не совпадает ни с одним из них — validator.py, Utils.py, __init__.py — просто не маршрут. EndoCore не импортирует его при роутинге, не логирует, ему нет до него дела. Это ровно так, будто его вообще нет под Api/, за исключением того, что если он лежит в папке с тем же именем, что и папка маршрута, — это тоже нормально: Api/v1/User/create_role.py рядом с Api/v1/User/Get.py не создаёт никакого конфликта.

WebSocket'ы используют другой набор стемов: Socket.py, Ws.py или Websocket.py (тоже регистронезависимо) отображаются на специальный псевдо-метод WEBSOCKET вместо обычного HTTP-глагола. См. WebSockets.

4. Папка [name] — динамический сегмент

Имя папки должно совпадать с ^\[(?P<name>[^\[\]]+)\]$ — квадратные скобки вокруг одного или более символов, которые сами не являются скобками. То, что находится между скобками, становится именем параметра пути, дословно, без приведения к нижнему регистру:

Api/v1/User/[id]/Get.py         -> path_params["id"]
Api/v1/Order/[OrderId]/Get.py   -> path_params["OrderId"]   (регистр сохранён!)

Это единственное место во всём дереве роутинга, где регистр не нормализуется — потому что этот текст должен ещё уметь совпадать с именем реального параметра обработчика для автоматического внедрения зависимостей. Если хотите, чтобы автоинъекция по имени вида user_id = Depends(...) работала, держите содержимое скобок валидным идентификатором Python ([id], [user_id]). Что угодно другое — [user-id] (дефис невалиден в идентификаторе) — всё равно работает как динамический сегмент, но прочитать его можно только через request.path_params["user-id"], никогда как одноимённый параметр функции.

Само захваченное значение всегда str (URL-декодировано — см. URL-декодирование ниже) — приводите его к нужному типу сами (int(request.path_params["id"])), если нужно число.

5. Статические папки побеждают динамические, если подходят обе

Api/v1/User/Me/Get.py       # GET /v1/user/me   -> побеждает статический обработчик "Me"
Api/v1/User/[id]/Get.py     # GET /v1/user/42    -> динамический обработчик

Резолвер — это trie (см. Архитектура): проходя /v1/user/me, в узле User он сначала проверяет, есть ли статический дочерний узел с именем me; и только если такого нет, откатывается на динамический дочерний узел (если он был зарегистрирован). Это значит, что можно выделить особый случай для одного конкретного значения (me, current, 0) рядом с универсальным [id] без какой-либо дополнительной настройки — просто добавить обе папки. Никакой неоднозначности в момент запроса не возникает, потому что статический поиск всегда побеждает безусловно.

На один узел допускается только один динамический потомок — и, в отличие от статического случая выше, конфликт имён здесь endo check НЕ ловит. Если существуют одновременно Api/v1/User/[id]/Get.py и Api/v1/User/[slug]/Post.py, узел trie для «динамического потомка User» создаётся один раз — тем файлом, который сканер посетит первым (отсортированный порядок путей), и его param_name берётся из имени скобок именно этого файла и больше никогда не меняется — собственное имя скобок второго файла для целей роутинга молча игнорируется. Конкретно: если Get.py под [id] зарегистрирован первым, то обработчик Post.py, живущий в папке [slug], всё равно получит request.path_params["id"], не "slug", даже если его собственная папка называется [slug]. Детектор дублей в endo check этого не ловит, потому что сравнивает полные шаблоны URL (/v1/user/{id} против /v1/user/{slug}) — а это разные строки, так что с точки зрения endo check ничего не дублируется. Избегайте этого полностью, используя одно и то же имя скобок для всех файлов методов, делящих одну позицию в дереве ([id] и для Get.py, и для Patch.py, и для Delete.py).

Контракт обработчика

Каждый файл endpoint'а определяет одну вещь: callable с именем handler.

from endocore import Request, Response

async def handler(request: Request) -> Response:
    return Response.json({"ok": True})
  • handler может быть async def (рекомендуется — см. почему) или обычным def; синхронные обработчики автоматически диспетчеризуются в рабочий поток, чтобы блокирующее тело не могло застопорить event loop для других запросов «в полёте». Async-обработчики остаются в loop'е.
  • Самая простая сигнатура — ровно один параметр с именем request без значения по умолчанию: диспетчер отдельно обрабатывает этот случай («тривиальный обработчик») и вызывает его напрямую, полностью пропуская механизм разрешения зависимостей ради небольшого выигрыша в производительности. Что-то более сложное — дополнительные параметры, Depends(...), аргумент с именем параметра пути — идёт через внедрение зависимостей.
  • Приведение возвращаемого значения: Response/StreamingResponse используется как есть; dict/list становится JSON 200; str становится текстовым 200; None становится пустым 204; кортеж (content, status) или (content, status, headers) строит Response из этих частей.
  • Опциональная def init(): ... на уровне модуля (без параметров) выполняется один раз, при старте, сразу после импорта модуля обработчика — место для одноразовой настройки, локальной для этого конкретного файла endpoint'а. Вызывается как обычный синхронный вызов (init()), без await — если объявить её как async def init(): ..., такой вызов просто создаст объект корутины и отбросит его, ни разу не выполнив тело (Python даже выведет RuntimeWarning: coroutine 'init' was never awaited). Держите init обычной def.

Файлы, не являющиеся маршрутами, и опция Services/

Любой файл, чей стем не является именем HTTP-метода (или стемом WebSocket), невидим для роутера где угодно в дереве — не нужно делать ничего особенного, чтобы Api/v1/User/Services/create_role.py или Api/v1/User/validators.py не стали маршрутами; их имена сами по себе (create_role, validators) уже их дисквалифицируют.

Единственная ситуация, где действительно нужно явное исключение, — это когда вспомогательный файл иначе выглядел бы как маршрут — например, вы держите небольшой локальный хелпер с именем Get.py, который не должен быть endpoint'ом. Для этого положите его в папку, буквально названную Services (точный регистр — services не совпадает), в любом месте пути; discovery.py проверяет каждую промежуточную папку на совпадение с {"Services", "__pycache__"} и пропускает весь файл (с причиной «inside non-route folder 'Services'»), если хоть одна совпала. Так же работают локальные сервисы конкретной версии: папка Services/ внутри Api/v1/User/ содержит код, который можно импортировать глобально, но который никогда не считается маршрутом и копируется вместе с версией при запуске endo version create.

Результаты резолва

Запрос резолвится ровно в один из трёх исходов:

  • 200 — для этого метода + пути существует обработчик. Выполняется как обычно.
  • 404 — такого пути вообще нет ни для какого метода, либо у запроса нет распознаваемого префикса версии (^v\d+$) — запрос без версии по умолчанию 404 (см. Default-to-latest, чтобы изменить это поведение), либо версия существует, но пути под ней нет.
  • 405 Method Not Allowed — путь существует (какой-то другой метод имеет там обработчик), но не для использованного вами метода. В ответе есть заголовок Allow, перечисляющий каждый метод, у которого есть обработчик по этому точному пути, например Allow: GET, POST.

URL-декодирование

Сегменты пути декодируются из URL перед сопоставлением (urllib.parse.unquote), так что /v1/user/O%27Brien резолвит id в буквальную строку O'Brien, а не в процентно-экранированную форму. Пустые сегменты отбрасываются — /v1//user, /v1/user и /v1/user/ разбиваются на один и тот же список сегментов, так что хвостовые/задвоенные слэши безвредны.

Просмотр и отладка маршрутов

endo routes      # каждый метод + URL + файл, на который он указывает, прямо из trie
endo check       # дубли маршрутов, битые обработчики и каждый пропущенный файл с причиной
endo doctor      # более широкая проверка проекта (версия Python, опциональные зависимости, структура)

endo check — самый быстрый способ найти опечатанную папку версии или неправильно расположенный файл: всё, что discovery.py пропустил с причиной, показывается там, даже если это никогда не влияет на работающее приложение (пропущенный файл сам по себе означает лишь «не маршрут» — это не ошибка).

Default-to-latest (по желанию)

По умолчанию запрос без префикса vN — это 404 — позиция EndoCore такова: «явное лучше неявного», и молчаливое угадывание версии для клиента, который забыл её указать, — это ровно тот тип неявного поведения, который потом приводит к продакшн-инцидентам. Можно включить это поведение по желанию:

endo dev --default-version latest

или в коде: Application(app_dir=..., default_version="latest"). При этом запрос без версии повторно резолвится против самой новой зарегистрированной версии (max по числовому суффиксу, так что v10 побеждает v2), и каждый раз, когда этот фолбэк реально обслуживает запрос, это логируетсяno version in GET /user/role -> served v3 — специально для того, чтобы «какую версию на самом деле тайно получает мой трафик без версии» никогда не оставалось молчаливой загадкой. Явная, но неизвестная версия (/v99/...) всё равно остаётся обычным 404: этот фолбэк срабатывает только когда у запроса вообще нет сегмента версии.