plecto_control/manifest/route.rs
1//! A routing rule (`[[route]]`, ADR 000013 / 000034) and its matching / rate-limit sub-config.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7/// One routing rule (ADR 000013 / 000034): match a request by the `[route.match]` dimensions, run
8/// an inline chain of `filters`, and forward to a single `upstream` (shorthand) or a weighted set of
9/// `backends` (traffic split / canary). `strip_prefix` is a **host-native** path rewrite applied to
10/// the *forwarded* request only (the chain still sees the original path) — the common reverse-proxy
11/// prefix-strip, without a `plecto:filter` contract change. `filters` / `strip_prefix` / `rate_limit`
12/// are per-route: they apply identically across every backend (ADR 000034 keeps a backend a pure
13/// `{upstream, weight}` pair; a route needing different policy per target uses a separate route).
14#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
15#[serde(deny_unknown_fields)]
16pub struct Route {
17 /// The match dimensions (`[route.match]`): host / path_prefix / method / headers / query (ADR
18 /// 000034). At least `path_prefix` is required; all other dimensions are optional and ANDed.
19 #[serde(rename = "match")]
20 pub matcher: RouteMatch,
21 /// This route's chain: filter ids run in order (may be empty for a pure pass-through route).
22 #[serde(default)]
23 pub filters: Vec<String>,
24 /// Single-upstream shorthand: the `[[upstream]]` `name` to forward a passing request to.
25 /// Mutually exclusive with `backends` (exactly one is required; validated at build). A single
26 /// `upstream` is normalised to a one-element weighted set (weight 1) at compile time.
27 #[serde(default)]
28 pub upstream: Option<String>,
29 /// Weighted traffic split (ADR 000034): forward to these `{upstream, weight}` backends in
30 /// proportion to their weights (canary). Mutually exclusive with `upstream`. Empty unless used.
31 #[serde(default)]
32 pub backends: Vec<Backend>,
33 /// If set and the forwarded path starts with it, strip it before forwarding to the upstream
34 /// (host-native rewrite; the chain saw the original path). E.g. `/api` + `/api/x` → `/x`.
35 #[serde(default)]
36 pub strip_prefix: Option<String>,
37 /// Native fast-path rate limit (ADR 000033): a coarse token-bucket baseline consulted BEFORE
38 /// this route's filter chain. Absent = unlimited (the default). Distinct from the per-filter
39 /// `host-ratelimit` capability (ADR 000026): this is the operator's native floor on a route
40 /// (or per client-IP), needs no WASM filter, and never crosses the WASM boundary.
41 #[serde(default)]
42 pub rate_limit: Option<RouteRateLimit>,
43 /// HTTP/1.1 Upgrade opt-in (ADR 000048): the Upgrade tokens this route tunnels. Absent =
44 /// deny-by-default (the Upgrade/Connection pair keeps being stripped as hop-by-hop).
45 #[serde(default)]
46 pub upgrade: Option<RouteUpgrade>,
47 /// Native response compression opt-in (ADR 000074 / 000075): negotiate a content coding against the
48 /// client's `Accept-Encoding` and compress eligible responses AFTER the response chain
49 /// (filters always see identity). Absent = never transform — deny-by-default, which is also
50 /// the per-route BREACH opt-out. Do **not** enable on routes that reflect secrets into the
51 /// response body (CSRF tokens, session nonces echoed from the request): compression + reflection
52 /// enables BREACH-class chosen-plaintext attacks against TLS. Leave those routes uncompressed.
53 #[serde(default)]
54 pub compression: Option<RouteCompression>,
55}
56
57/// A route's Upgrade declaration (`[route.upgrade]`, ADR 000048). The allowlist shape is the
58/// h2c-smuggling mitigation (only listed tokens are ever re-issued upstream); `h2c` itself is
59/// rejected at validation (ADR 000015 — Plecto has no h2c on either side).
60#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
61#[serde(deny_unknown_fields)]
62pub struct RouteUpgrade {
63 /// Upgrade tokens to tunnel, matched case-insensitively against the client's `Upgrade`
64 /// header (e.g. `["websocket"]`). Must be non-empty; `h2c` is rejected.
65 pub protocols: Vec<String>,
66 /// Idle timeout for an established tunnel, in ms — a byte in EITHER direction resets it
67 /// (the activity-based form nginx/Envoy/HAProxy all share). `0` disables the timer.
68 #[serde(default = "default_upgrade_idle_timeout_ms")]
69 pub idle_timeout_ms: u64,
70}
71
72/// 5 minutes — Envoy's stream-idle default; long enough for ping/pong-quiet apps, short enough
73/// that an abandoned tunnel cannot hold a connection permit for hours.
74fn default_upgrade_idle_timeout_ms() -> u64 {
75 300_000
76}
77
78/// A route's compression declaration (`[route.compression]`, ADR 000074). Every field has a safe
79/// default, so the bare block header is the whole opt-in; the fields exist to narrow it.
80#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
81#[serde(deny_unknown_fields)]
82pub struct RouteCompression {
83 /// The codings this route offers, in SERVER-PREFERENCE order — the tie-break when the
84 /// client's qvalues don't decide (RFC 9110 §12.5.3 orders only by qvalue). Default
85 /// `["zstd", "br", "gzip"]`: best ratio-per-CPU first, universal fallback last.
86 #[serde(default = "default_compression_algorithms")]
87 pub algorithms: Vec<CompressionAlgorithm>,
88 /// Don't compress a response whose declared `Content-Length` is below this (bytes). Under
89 /// ~1 KiB the codec dictionary + trailer can exceed the saving (common practice defaults
90 /// cluster around tens of bytes to ~1 KiB; 1024 is the safe middle). A response with NO
91 /// declared length (streamed / chunked) is always eligible — its size is unknowable up front.
92 #[serde(default = "default_compression_min_length")]
93 pub min_length: u64,
94 /// The `type/subtype` allowlist (matched against the response `Content-Type` essence,
95 /// case-insensitive, parameters ignored). REPLACES the default when set. The default covers
96 /// the textual web types; already-compressed media (images, video, archives) and
97 /// `text/event-stream` (a compressor buffering an SSE stream stalls events) stay excluded.
98 #[serde(default = "default_compression_content_types")]
99 pub content_types: Vec<String>,
100}
101
102/// A content coding Plecto can produce (ADR 000074: gzip baseline + zstd / brotli). The serde
103/// spelling is the wire token (`Accept-Encoding` / `Content-Encoding`), so the manifest reads
104/// exactly like the negotiation it configures.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, schemars::JsonSchema, Serialize)]
106#[serde(rename_all = "kebab-case")]
107pub enum CompressionAlgorithm {
108 /// RFC 8878 Zstandard. Encoded with window_log ≤ 23: RFC 9659 forbids frames over an 8 MiB
109 /// window for web content (browsers reject them).
110 Zstd,
111 /// RFC 7932 Brotli.
112 Br,
113 /// RFC 9110 §8.4.1.3 gzip — the universally-supported baseline.
114 Gzip,
115}
116
117impl CompressionAlgorithm {
118 /// The registered content-coding token (what goes on the wire in `Content-Encoding`).
119 pub fn token(self) -> &'static str {
120 match self {
121 CompressionAlgorithm::Zstd => "zstd",
122 CompressionAlgorithm::Br => "br",
123 CompressionAlgorithm::Gzip => "gzip",
124 }
125 }
126}
127
128fn default_compression_algorithms() -> Vec<CompressionAlgorithm> {
129 vec![
130 CompressionAlgorithm::Zstd,
131 CompressionAlgorithm::Br,
132 CompressionAlgorithm::Gzip,
133 ]
134}
135
136fn default_compression_min_length() -> u64 {
137 1024
138}
139
140/// The default compressible-content allowlist: the textual web types every major proxy ships
141/// (HTML/CSS/JS/JSON/XML feeds/SVG) plus `application/wasm` (components compress well and are
142/// Plecto's own distribution format). Notably ABSENT: `text/event-stream` and all
143/// already-compressed media.
144fn default_compression_content_types() -> Vec<String> {
145 [
146 "text/html",
147 "text/css",
148 "text/plain",
149 "text/xml",
150 "text/javascript",
151 "application/javascript",
152 "application/x-javascript",
153 "application/json",
154 "application/xml",
155 "application/xhtml+xml",
156 "application/rss+xml",
157 "application/atom+xml",
158 "image/svg+xml",
159 "application/wasm",
160 ]
161 .map(str::to_string)
162 .to_vec()
163}
164
165/// The match dimensions of a route (`[route.match]`, ADR 000034), modelled on Gateway-API v1.5.0
166/// HTTPRoute matching. A request matches when EVERY specified dimension matches (AND); an
167/// unspecified dimension is a wildcard. Among matching routes the most specific wins (see
168/// `route::select`): host-constrained > longest `path_prefix` > `method` present > more header
169/// matches > more query matches, with manifest order the final stable tie-break.
170#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
171#[serde(deny_unknown_fields)]
172pub struct RouteMatch {
173 /// Match only this authority (case-insensitive, port ignored). `None` matches any host.
174 #[serde(default)]
175 pub host: Option<String>,
176 /// Match requests whose path starts with this prefix (on a `/` boundary). Longest wins.
177 pub path_prefix: String,
178 /// Match only this HTTP method (exact, upper-case token, e.g. `"POST"`). `None` matches any.
179 #[serde(default)]
180 pub method: Option<String>,
181 /// Header matches: every entry must be present with an exact value. Header NAME is matched
182 /// case-insensitively (lower-cased here at parse-ish time); the VALUE is matched byte-exact.
183 /// `BTreeMap` (not `HashMap`) to keep the manifest's deterministic-serialisation invariant.
184 #[serde(default)]
185 pub headers: BTreeMap<String, String>,
186 /// Query-parameter matches: every entry must be present with an exact value. Parameter NAME is
187 /// case-sensitive (Gateway-API semantics, asymmetric with headers); the VALUE is matched exact.
188 #[serde(default)]
189 pub query: BTreeMap<String, String>,
190}
191
192/// One weighted backend of a route's traffic split (`[[route.backends]]`, ADR 000034): the
193/// `[[upstream]]` `name` and its integer `weight`. The proportion a backend receives is
194/// `weight / Σweights` (Gateway-API semantics). `weight` defaults to 1, caps at 1_000_000 (Σ
195/// overflow guard), and `0` drains the backend (no traffic). Validated at build (ADR 000034 5b).
196#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
197#[serde(deny_unknown_fields)]
198pub struct Backend {
199 /// The `[[upstream]]` `name` this backend forwards to.
200 pub upstream: String,
201 /// This backend's integer weight in the split (default 1, max 1_000_000, `0` = drain).
202 #[serde(default = "default_weight")]
203 pub weight: u32,
204}
205
206fn default_weight() -> u32 {
207 1
208}
209
210/// Upper bound on a single backend weight (Gateway-API `Maximum=1000000`). Caps the summed weight
211/// so the weighted-split accumulator and the precomputed table stay bounded (ADR 000034 5b).
212pub(crate) const MAX_BACKEND_WEIGHT: u32 = 1_000_000;
213
214impl Route {
215 /// This route's forwarding targets as `(upstream_name, weight)` pairs (ADR 000034): the single
216 /// `upstream` shorthand normalised to one weight-1 backend, or the explicit weighted `backends`.
217 /// EXACTLY ONE of the two must be set — both or neither is a config error (returned as a reason
218 /// the caller wraps with the route's context). Borrows the names from `self` (no allocation).
219 pub(crate) fn targets(&self) -> Result<Vec<(&str, u32)>, &'static str> {
220 match (self.upstream.as_deref(), self.backends.as_slice()) {
221 (Some(_), [_, ..]) => {
222 Err("a route sets both `upstream` and `backends`; set exactly one")
223 }
224 (None, []) => Err("a route sets neither `upstream` nor `backends`; set exactly one"),
225 (Some(name), []) => Ok(vec![(name, 1)]),
226 (None, backends) => Ok(backends
227 .iter()
228 .map(|b| (b.upstream.as_str(), b.weight))
229 .collect()),
230 }
231 }
232}
233
234/// Native per-route rate-limit spec (ADR 000033), declared as `[route.rate_limit]`. A coarse
235/// token bucket the fast path consults before forwarding — the Tier-0 baseline a filterless route
236/// otherwise lacks. `rate`/`burst` map onto the same token-bucket math as `host-ratelimit`
237/// (`capacity = burst`, refill `rate` tokens every second), but the surface is deliberately the
238/// friendlier two-knob (rate + burst) form, since this is a blunt floor, not a policy limiter.
239#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq)]
240#[serde(deny_unknown_fields)]
241pub struct RouteRateLimit {
242 /// Sustained requests per second (tokens added each second). Must be non-zero.
243 pub rate: u64,
244 /// Burst capacity: the most tokens the bucket holds (and starts full with). Must be non-zero.
245 pub burst: u64,
246 /// What the bucket counts against (default `route`). `route` shares one bucket across every
247 /// client of the route (a total floor); `client-ip` gives each client its own bucket (fairness
248 /// between clients), keyed on the connection peer (v4 /32, v6 /64).
249 #[serde(default)]
250 pub key: RateLimitKeyKind,
251}
252
253/// The dimension a native route rate limit counts against (ADR 000033).
254#[derive(
255 Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema, Serialize, PartialEq, Eq,
256)]
257#[serde(rename_all = "kebab-case")]
258pub enum RateLimitKeyKind {
259 /// One shared bucket for the whole route — a total cap regardless of client.
260 #[default]
261 Route,
262 /// A per-client-IP bucket (peer address, v4 /32 + v6 /64), bounded to a fixed-size table.
263 ClientIp,
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::manifest::Manifest;
270
271 #[test]
272 fn route_rate_limit_defaults_absent_and_parses_with_key() {
273 // Absent `[route.rate_limit]` → no native limiter (unlimited), the default.
274 let m = Manifest::from_toml(
275 r#"
276[[route]]
277upstream = "a"
278[route.match]
279path_prefix = "/"
280"#,
281 )
282 .unwrap();
283 assert!(
284 m.routes[0].rate_limit.is_none(),
285 "an absent rate_limit is unlimited"
286 );
287
288 // Present → rate/burst are read; `key` defaults to `route`.
289 let m2 = Manifest::from_toml(
290 r#"
291[[route]]
292upstream = "a"
293[route.match]
294path_prefix = "/"
295[route.rate_limit]
296rate = 100
297burst = 200
298"#,
299 )
300 .unwrap();
301 let rl = m2.routes[0].rate_limit.unwrap();
302 assert_eq!(rl.rate, 100);
303 assert_eq!(rl.burst, 200);
304 assert_eq!(rl.key, RateLimitKeyKind::Route, "key defaults to route");
305
306 // `key = "client-ip"` is the kebab-case spelling.
307 let m3 = Manifest::from_toml(
308 r#"
309[[route]]
310upstream = "a"
311[route.match]
312path_prefix = "/"
313[route.rate_limit]
314rate = 5
315burst = 5
316key = "client-ip"
317"#,
318 )
319 .unwrap();
320 assert_eq!(
321 m3.routes[0].rate_limit.unwrap().key,
322 RateLimitKeyKind::ClientIp
323 );
324 }
325}