Маршрутизация
Router — сегмент-trie (Axum-класса): литеральные сегменты, {name}-параметры
и {*rest}-catch-all, сопоставляемые по структурному приоритету — не по
порядку регистрации. Сама регистрация — fallible: конфликтующий route — это
типизированный Result, никогда не паника.
Дизайн: Plan 222.1
(репа nova). Исходник: src/server_router.nv.
Содержание
- Регистрация route
- Две формы регистрации: statement и chain
- Шаблоны путей
MethodRouter: композиция методов на одном путиnest: суб-маршрутизаторы с префиксом- Fallback’и: глобальный 404 vs per-route 405
- Конфликты routes — типизированные ошибки
- Typed-маршруты
- Связанные документы
Регистрация route
test "routing: statement-form registration with !!" {
mut r = Router.new()
r.get("/health", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "ok"))!!
ro wire = serve_once(r, get_req("/health"))
assert(status_line(wire) == "HTTP/1.1 200 OK")
}
Router.mut @get/@post/@put/@delete/@patch(path, handler) каждый возвращает
Result[Router, HttpError] — не Result[(), HttpError]. Payload у Ok
— сам маршрутизатор (Ok(@)), это и делает возможными обе формы регистрации ниже
на одной и той же сигнатуре.
Голая fn/замыкание в позиции, ожидающей Handler, автоподнимается в
Handler (type Handler fn(ServerRequest) -> ServerResponse — newtype над
fn-типом, не алиас): r.get(path, fn(req ServerRequest) -> ServerResponse { ... })
не требует оборачивающего вызова. Значение Handler вызывается напрямую
(h(req)) — см. handlers-response.md.
Две формы регистрации: statement и chain
Поскольку payload у Ok — сам маршрутизатор, регистрация компонуется двумя
способами:
fn chained_routes() -> Result[Router, HttpError] {
mut r = Router.new()
r.get("/a", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "a"))?
.post("/b", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "b"))
}
test "routing: ?-chain inside a Result-returning fn, and !!-chain on an rvalue" {
ro r1 = chained_routes()!!
assert(status_line(serve_once(r1, get_req("/a"))) == "HTTP/1.1 200 OK")
ro r2 = Router.new()
.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "x"))!!
.post("/y", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "y"))!!
assert(status_line(serve_once(r2, post_req("/y", ""))) == "HTTP/1.1 200 OK")
}
- Statement-форма —
r.get(..)!!отдельной строкой, один вызов на строку. Большинство кода в этом наборе доков использует именно её; читается ближе всего к Express/chi. ?-chain — внутри функции, которая сама возвращаетResult[Router, HttpError], регистрации цепляются через?, и первая же ошибка коротко замыкает всю функцию.- Fluent
!!-chain на rvalue —Router.new().get(..)!!.post(..)!!— удобно для определения маршрутизатора одним выражением (таблица-константа в начале модуля).
Обе формы вызывают ровно одну и ту же машинерию @route/@insert_segs —
выбирайте ту, что лучше читается в конкретном месте.
Шаблоны путей
test "routing: {name} path params and {*rest} catch-all" {
mut r = Router.new()
r.get("/users/{id}", fn(req ServerRequest) -> ServerResponse {
ro id = req.param("id") ?? "?"
ServerResponse.text(StatusCode.OK, "id=${id}")
})!!
r.get("/files/{*path}", fn(req ServerRequest) -> ServerResponse {
ro path = req.param("path") ?? "?"
ServerResponse.text(StatusCode.OK, "path=${path}")
})!!
assert(wire_str(serve_once(r, get_req("/users/42"))).contains("id=42"))
assert(wire_str(serve_once(r, get_req("/files/a/b/c"))).contains("path=a/b/c"))
}
{name}совпадает ровно с одним сегментом пути; его декодированное значение читается черезreq.param("name")(см. handlers-response.md).{*name}— catch-all — обязан быть последним сегментом шаблона ({*rest}где-либо ещё — ошибкаErrна этапе регистрации), а его значение — склеенный обратно, percent-декодированный остаток пути.- Приоритет структурный: литеральные сегменты выигрывают у
{name}, который выигрывает у{*name}, с backtracking’ом на тупике глубже по дереву — независимо от порядка регистрации routes. Так же ведёт себя Axum (иnet/httpиз Go 1.22), в отличие от линейного маршрутизатора first-match.
MethodRouter: композиция методов на одном пути
test "routing: MethodRouter composes get(h).post(h2) on one path, 405+Allow otherwise" {
mut r = Router.new()
r.route("/widgets",
get(fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "list"))
.post(fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.CREATED, "made")))!!
assert(status_line(serve_once(r, get_req("/widgets"))) == "HTTP/1.1 200 OK")
assert(status_line(serve_once(r, post_req("/widgets", ""))) == "HTTP/1.1 201 Created")
ro wire405 = serve_once(r, "DELETE /widgets HTTP/1.1\r\nHost: x\r\n\r\n".bytes())
assert(status_line(wire405) == "HTTP/1.1 405 Method Not Allowed")
assert(wire_str(wire405).contains("allow: GET, POST"))
}
get(h)/post(h)/put(h)/delete(h)/patch(h) — свободные функции,
начинающие цепочку MethodRouter (форма Axum’а get(handler).post(handler2));
Router.@route(path, mr) регистрирует весь набор атомарно на одном пути.
Пять методов Router.@get/@post/@put/@delete/@patch(path, h),
используемые в остальной части набора доков — сахар над
@route(path, get(h)) и т.п. — обе формы дают одну и ту же запись в дереве.
Когда путь совпал, а зарегистрированного метода нет, Polaris отвечает
общим 405 Method Not Allowed с заголовком Allow: <methods>, собранным из
того, что реально зарегистрировано на этом route — никакого дополнительного
кода не нужно.
nest: суб-маршрутизаторы с префиксом
test "routing: nest merges a sub-router's routes under a prefix" {
mut api = Router.new()
api.get("/widgets/{id}", fn(req ServerRequest) -> ServerResponse {
ro id = req.param("id") ?? "?"
ServerResponse.text(StatusCode.OK, "widget ${id}")
})!!
mut r = Router.new()
r.nest("/api", api)!!
assert(wire_str(serve_once(r, get_req("/api/widgets/9"))).contains("widget 9"))
}
r.nest(prefix, sub) перерегистрирует каждый уже конфликт-свободный route из
sub в r, под prefix — конфликт префикса с уже существующим route в r
— как всегда, типизированный Err, не паника. Собственный @fallback у
sub (его 404 маршрутизатора) не переносится — участвуют только верхнеуровневый
Router.@fallback и per-route MethodRouter.@fallback; про то, как nest
взаимодействует с .use(), — в
middleware.md.
Fallback’и: глобальный 404 vs per-route 405
test "routing: Router.fallback (global 404) vs MethodRouter.fallback (per-route 405)" {
mut r = Router.new()
r.fallback(fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.NOT_FOUND, "custom 404"))
mut mr = get(fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "ok"))
mr.fallback(fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.METHOD_NOT_ALLOWED, "custom 405"))
r.route("/guarded", mr)!!
assert(wire_str(serve_once(r, get_req("/missing"))).contains("custom 404"))
assert(wire_str(serve_once(r, post_req("/guarded", ""))).contains("custom 405"))
}
Два разных хука, легко перепутать по имени:
| Хук | Срабатывает когда | Область |
|---|---|---|
Router.mut @fallback(h) | ни один путь в дереве не совпал вообще | глобально — 404 всего маршрутизатора |
MethodRouter.mut @fallback(h) | путь совпал, а метод — нет | 405 конкретного route’а |
Ни один не задан по умолчанию: незаданный Router.@fallback отдаёт простой
404 page not found; незаданный MethodRouter.@fallback отдаёт общий
405 + Allow, показанный выше.
Конфликты routes — типизированные ошибки
test "routing: a duplicate/conflicting route registration is a typed Err, not a panic" {
mut r = Router.new()
r.get("/dup", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "first"))!!
ro second = r.route("/dup", get(fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "second")))
assert(match second { Err(_) => true, Ok(_) => false })
}
Axum на эквивалентном конфликте паникует в момент построения маршрутизатора;
Polaris вместо этого возвращает типизированный Err(HttpError) — сознательное
улучшение, заложенное в дизайн. Ловятся три формы конфликта: точный дубль
пути, два разных имени {name}-параметра, претендующих на один и тот же
слот дерева, и {*rest}, не являющийся последним сегментом шаблона.
Typed-маршруты
Каждый Router.@get/@post/@put/@delete/@patch(path, Handler) выше
регистрирует обычный, нетипизированный обработчик. Router.@get_typed/
@post_typed/… регистрируют вместо этого TypedRoute — та же диспетчеризация,
но TypeShape запроса/ответа route’а дополнительно записываются для
Router.introspect(). @post_typed_h идёт на шаг дальше: голый обработчик
fn(T) -> ServerResponse плюс один type-параметр, без ручного извлечения и
без литерала TypedRoute —
r.post_typed_h[AddNoteReq]("/notes/{id}", add_note)!!
— где AddNoteReq — тип, чьи поля каждое объявляет свой собственный
источник извлечения (#impl(FromPath)/#impl(FromQuery)/#impl(FromBody)).
Полное описание, включая короткое замыкание при отказе и связь с OpenAPI:
extractors.md.
Связанные документы
Полный пример: examples/02-routing — каждый приём этой страницы, реально запущенный.
- handlers-response.md —
ServerRequest/ServerResponse, чтение параметров,Handler - extractors.md —
FromRequest,TypedRoute/*_typed/*_typed_h, связь с OpenAPI - middleware.md —
Router.@useи его взаимодействие с@nest - errors.md —
HttpErrorи то, как он превращается в ответ на проводе src/server_router.nv— реализация дереваsrc/router_test.nv— полный набор pin-тестов, из которого взяты примеры этой страницы