Батарейки: cors, compress, log, ratelimit
Четыре готовые реализации Middleware, каждая — свой
модуль, каждая строится простой функцией, которую передают в Router.@use.
cors/logger стоят за типом-конфигом + приватным @middleware() +
публичной одноимённой свободной функцией (cors(cfg), logger(cfg)) —
одна публичная точка входа, тип-конфиг остаётся ради своей многополевой,
чейнящейся формы. compression/ratelimit вовсе обходятся без
типа-конфига — скалярные параметры со значениями по умолчанию и есть вся
поверхность (compression(...), ratelimit(...)).
| Батарейка | Модуль | Семантика |
|---|---|---|
| cors | polaris.middleware.cors | tower-http CorsLayer |
| compress | polaris.middleware.compress | tower-http CompressionLayer (только gzip) |
| log | polaris.middleware.log | chi Logger + RequestID + RealIP, слитые в один |
| ratelimit | polaris.middleware.ratelimit | chi Throttle / tower::limit, поверх TokenBucket из std |
Исходник: src/middleware/cors.nv,
compress.nv, log.nv,
ratelimit.nv.
cors
test "batteries: cors — preflight answered 204, simple request decorated" {
mut c = Cors.new()
c.allow_origin("https://app.example")
mut r = Router.new()
r.use(cors(c))
r.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "ok"))!!
ro simple = route_once(r, get_req_h("/x", "Origin", "https://app.example"))
assert(simple.status_code() == 200)
assert(hdr(simple, "Access-Control-Allow-Origin") == "https://app.example")
ro preflight_raw = "OPTIONS /x HTTP/1.1\r\nHost: n\r\nOrigin: https://app.example\r\nAccess-Control-Request-Method: GET\r\n\r\n".bytes()
ro preflight = route_once(r, preflight_raw)
assert(preflight.status_code() == 204)
}
Cors.new() стартует строго (ничего не разрешено); Cors.permissive()
разрешает любой origin/метод/заголовок без credentials (форма
tower-http’шного permissive()). Методы-билдеры: @allow_origin(origin)
(повторяемо), @allow_any_origin(), @allow_method(m)/@allow_any_methods(),
@allow_header(name)/@allow_any_headers(), @expose_header(name),
@credentials(bool), @max_age(secs).
Preflight-запросы OPTIONS (с Access-Control-Request-Method) отвечаются
полностью самим промежуточным обработчиком — 204, next не вызывается вовсе,
собственный 405-fallback обёрнутого route’а никогда не показывается.
Access-Control-Allow-Origin: * вместе с credentials(true) запрещено
спецификацией, и @middleware() на такой конфигурации паникует — как
и tower-http, на том основании, что такая комбинация всегда является багом
вызывающего кода (D325), а не живым сетевым вводом.
compress
test "batteries: compress — gzip only above min_size and when the client accepts it" {
mut r = Router.new()
r.use(compression())
consume sb = StringBuilder.new()
mut i = 0
while i < 100 { sb.append("the quick brown fox jumps over the lazy dog; "); i += 1 }
ro big = sb.into_str()
r.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, big))!!
ro accepted = route_once(r, get_req_h("/x", "Accept-Encoding", "gzip"))
assert(hdr(accepted, "Content-Encoding") == "gzip")
ro declined = route_once(r, get_req("/x"))
assert(hdr(declined, "Content-Encoding") == "")
}
compression(min_size: int = 1024, level: CompressLevel = CompressLevel.default())
— по умолчанию минимальный размер 1024 байта и дефолтный уровень gzip
(compression(min_size: 4096), compression(level: CompressLevel.best())
для настройки; параметры со значением по умолчанию всегда передаются по
имени). Пропускается автоматически — никогда не баг наложить его везде —
когда: ответ уже стримится (chunk-продюсер выигрывает провод), тело меньше min_size, ответ
уже несёт Content-Encoding, content-type не в allowlist для сжатия
(text/* + json/xml/javascript-подобные подтипы), либо клиентский
Accept-Encoding не допускает gzip. Vary: accept-encoding добавляется
всегда, когда ответ мог бы быть согласуемым, даже если конкретно этот
ответ остался identity — чтобы разделяемый кэш никогда не отдал gzip-тело
клиенту, который не умеет его раскодировать. Brotli не предлагается —
пакет compress, лежащий в основе, поставляет только декодер, без
энкодера, поэтому согласование br намеренно отсутствует, пока энкодер
не появится.
log
test "batteries: log — one line per request, X-Request-Id propagated" {
mut lines []str = []
ro cfg = AccessLog.new()
with Time = th.fixed_ms(0), Log = capture_log(lines) {
mut r = Router.new()
r.use(logger(cfg))
r.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "ok"))!!
ro resp = route_once(r, get_req("/x"))
assert(hdr(resp, "x-request-id") == "req-1")
assert(lines.len() == 1)
assert(lines[0] == "[req-1] GET /x -> 200 2B in 0ms")
}
}
Одна строка на запрос: метод, путь, статус, размер тела ответа, wall-clock
длительность. AccessLog.new() по умолчанию — request-id включён,
real-ip выключен; строки идут через ambient
эффект Log — по умолчанию в stdout,
перенаправляемо в тестах через with Log = capture_log(lines) { ... }
(тест выше перехватывает их в Vec[str] — скрейпинг stdout не нужен и в
ваших собственных тестах). X-Request-Id берётся из входящего заголовка,
если он присутствует и безопасен, иначе генерируется из счётчика
конкретного конфига (req-1, req-2, …) и эхом возвращается в ответе.
@real_ip(true) добавляет в строку первый хоп X-Forwarded-For — по
умолчанию выключено, тот же предупреждающий комментарий, что у
chi’шного RealIP: этот заголовок контролируется клиентом, доверяйте ему
только за прокси, который его перезаписывает.
@middleware() несёт эффект-ряд Time (он измеряет wall-clock
длительность вокруг обёрнутого обработчика) — тесты фиксируют часы через
with Time = th.fixed_ms(...) (std.testing.handlers) для
детерминированного вывода, как показано выше. Log в этом ряду НЕТ:
строка запроса эмитится сырым опом Log.info(...) (не проверяется под
--strict-effects, см. serving.md), поэтому
он свободно комбинируется в одном with Time = ..., Log = ... { ... }.
ratelimit
test "batteries: ratelimit — burst within capacity passes, then 429 + Retry-After" {
with Time = th.fixed_ms(0) {
mut r = Router.new()
r.use(ratelimit(1, 1.0))
r.get("/x", fn(req ServerRequest) -> ServerResponse => ServerResponse.text(StatusCode.OK, "ok"))!!
assert(route_once(r, get_req("/x")).status_code() == 200)
ro second = route_once(r, get_req("/x"))
assert(second.status_code() == 429)
assert(hdr(second, "retry-after") == "1")
}
}
ratelimit(capacity int, per_sec f64, per_client bool = false) —
capacity токенов burst’а, пополняемых со скоростью per_sec токенов в
секунду, поверх TokenBucket из std. По умолчанию — один глобальный
bucket (форма chi’шного Throttle); ratelimit(2, 1.0, per_client: true)
разделяет buckets по первому хопу X-Forwarded-For (тот же предупреждающий
комментарий про доверие, что и у RealIP в log; параметры со значением
по умолчанию всегда передаются по имени — per_client: true, никогда
голым третьим позиционным). Отклонённый запрос получает 429 +
Retry-After: <ceil(1/per_sec)> секунд — ближайший момент, когда токен
может появиться снова. Как и log, построение промежуточного обработчика
несёт эффект-ряд Time (bucket пополняется относительно
Monotonic.now()); тесты
фиксируют часы тем же способом.
Известное упрощение: bucket не защищён локом — под настоящей M:N-параллельностью два fiber’а в принципе могут одновременно увидеть последний токен. Over-admission примерно на один токен под конкуренцией throttle’ит, но не портит состояние.
Связанные документы
Полный пример: examples/04-middleware — log+ratelimit реально запущенные (см. также 10-mini-service — log в сервисе побольше).
- middleware.md — ядро
Middleware/Router.@use, на котором это построено - auth.md —
require_jwt/session, ещё два готовых промежуточных обработчика src/middleware/— полный исходник + pin-тесты для всех четырёх