Роутинг¶
Роутер — сердце EndoCore. Здесь нет таблицы маршрутов, которую вы пишете и
поддерживаете руками — дерево папок под Api/ и есть таблица маршрутов.
На этой странице правило разобрано полностью, включая все граничные случаи,
которые резолвер реально реализует (сверено с исходниками
endocore/core/discovery.py, endocore/core/router.py и
endocore/core/registry.py — это не «примерно как это работает», а именно
как это работает).
Ментальная модель¶
При старте EndoCore делает один обход файловой системы по Api/
(rglob("*.py"), значит рекурсивно заходит во все подпапки). Для каждого
найденного .py-файла он смотрит на имя файла и его путь и решает
одну из трёх вещей:
- Это endpoint — имя файла совпадает с HTTP-методом, файл лежит внутри папки версии, и он не находится внутри папки, которая исключена из роутинга. Из него делается маршрут.
- Файл пропущен с причиной — имя похоже на маршрут (совпадает с
методом), но что-то не так с его расположением (нет папки версии, файл
внутри
Services/). Это видно вendo checkи в логе загрузки. - Это обычный код — имя файла вообще не является 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/42 → id = "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¶
Каждая промежуточная папка становится одним сегментом пути, в нижнем регистре:
Так что 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 такова:
«явное лучше неявного», и молчаливое угадывание версии для клиента, который
забыл её указать, — это ровно тот тип неявного поведения, который потом
приводит к продакшн-инцидентам. Можно включить это поведение по желанию:
или в коде: Application(app_dir=..., default_version="latest"). При этом
запрос без версии повторно резолвится против самой новой зарегистрированной
версии (max по числовому суффиксу, так что v10 побеждает v2), и
каждый раз, когда этот фолбэк реально обслуживает запрос, это
логируется — no version in GET /user/role -> served v3 — специально для
того, чтобы «какую версию на самом деле тайно получает мой трафик без
версии» никогда не оставалось молчаливой загадкой. Явная, но неизвестная
версия (/v99/...) всё равно остаётся обычным 404: этот фолбэк срабатывает
только когда у запроса вообще нет сегмента версии.