Skip to main content

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}