Skip to main content

featherbit/plugins/native/
traffic_label.rs

1//! Traffic labeling plugin (`traffic-label`) — a faithful subset of Apache
2//! APISIX's `traffic-label` plugin: match requests with condition expressions
3//! and tag them, picking among weighted actions.
4//!
5//! The APISIX plugin supports one action kind, `set_headers`, plus a `weight`
6//! for weighted selection between an action group's entries; featherbit adds
7//! `set_labels`, which writes `label.<key>` entries into `context.message` so
8//! downstream nodes (logging, scripts, upstream selection) can read the label
9//! without touching the wire request.
10//!
11//! This node never fails at execution time and is always pass-through: wire
12//! `success` onward; the `error` port is never taken.
13
14use async_trait::async_trait;
15use std::collections::HashMap;
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use crate::context::Context;
19use crate::plugins::{Plugin, PluginOutput, PluginResult};
20use crate::vars::template::Template;
21use crate::vars::Expr;
22
23/// Applies the first matching rule's action: header values and label values
24/// are `{{namespace.path}}` (plus legacy `$var`) templates interpolated per
25/// request.
26pub struct TrafficLabelPlugin {
27    rules: Vec<Rule>,
28}
29
30struct Rule {
31    /// One APISIX triple-array expression (rules AND-ed); `None` matches all.
32    matcher: Option<Expr>,
33    actions: Vec<ActionEntry>,
34    total_weight: u64,
35    /// Round-robin cursor for weighted selection between `actions`.
36    cursor: AtomicU64,
37}
38
39struct ActionEntry {
40    weight: u64,
41    /// Lowercased header name → value template, set on the request.
42    set_headers: Vec<(String, Template)>,
43    /// Label key → value template, written to `message["label.<key>"]`.
44    set_labels: Vec<(String, Template)>,
45}
46
47/// Parses a `{name: scalar-template}` object into ordered pairs; numbers and
48/// bools are stringified, nested shapes are rejected.
49fn parse_kv_object(v: &serde_json::Value, field: &str) -> Result<Vec<(String, String)>, String> {
50    let obj = v
51        .as_object()
52        .ok_or_else(|| format!("{field} must be an object of name: value"))?;
53    obj.iter()
54        .map(|(k, v)| {
55            let value = match v {
56                serde_json::Value::String(s) => s.clone(),
57                serde_json::Value::Number(n) => n.to_string(),
58                serde_json::Value::Bool(b) => b.to_string(),
59                _ => return Err(format!("{field}['{k}'] must be a scalar value")),
60            };
61            Ok((k.clone(), value))
62        })
63        .collect()
64}
65
66impl TrafficLabelPlugin {
67    /// Builds the plugin from node config. All expressions are validated
68    /// here at config load.
69    ///
70    /// Accepted keys:
71    /// - `rules` (array, **required**, non-empty): evaluated in order; the
72    ///   first rule whose `match` passes applies one of its actions.
73    ///   - `match` (array, optional): APISIX triple-array condition, rules
74    ///     AND-ed. Omit to match every request.
75    ///   - `actions` (array of objects, **required**, non-empty): one entry
76    ///     is chosen by weighted round-robin. Each entry:
77    ///     - `set_headers` (object `{name: value}`): request headers to set
78    ///       (replacing existing values); values support `{{namespace.path}}`
79    ///       references plus legacy `$var` interpolation.
80    ///     - `set_labels` (object `{key: value}`, featherbit extension):
81    ///       written to `context.message` as `label.<key>`; values support
82    ///       `{{namespace.path}}` references plus legacy `$var`
83    ///       interpolation.
84    ///     - `weight` (integer >= 1, default `1`): selection weight.
85    ///
86    /// ```yaml
87    /// type: traffic-label
88    /// config:
89    ///   rules:
90    ///     - match:
91    ///         - ["arg_channel", "==", "beta"]
92    ///       actions:
93    ///         - set_headers:
94    ///             x-server-id: beta
95    ///           set_labels:
96    ///             tier: "beta"
97    ///           weight: 3
98    ///         - set_headers:
99    ///             x-server-id: stable
100    ///           weight: 1
101    /// ```
102    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
103        let raw_rules = config
104            .get("rules")
105            .and_then(|v| v.as_array())
106            .filter(|r| !r.is_empty())
107            .ok_or("traffic-label requires a non-empty 'rules' array")?;
108
109        let mut rules = Vec::with_capacity(raw_rules.len());
110        for (idx, raw) in raw_rules.iter().enumerate() {
111            let obj = raw
112                .as_object()
113                .ok_or_else(|| format!("rules[{idx}] must be an object"))?;
114
115            let matcher = match obj.get("match") {
116                None => None,
117                Some(v) => Some(Expr::parse(v).map_err(|e| format!("rules[{idx}].match: {e}"))?),
118            };
119
120            let raw_actions = obj
121                .get("actions")
122                .and_then(|v| v.as_array())
123                .filter(|a| !a.is_empty())
124                .ok_or_else(|| format!("rules[{idx}] requires a non-empty 'actions' array"))?;
125
126            let mut actions = Vec::with_capacity(raw_actions.len());
127            for (aidx, raw_action) in raw_actions.iter().enumerate() {
128                let aobj = raw_action
129                    .as_object()
130                    .ok_or_else(|| format!("rules[{idx}].actions[{aidx}] must be an object"))?;
131
132                let mut entry = ActionEntry {
133                    weight: 1,
134                    set_headers: Vec::new(),
135                    set_labels: Vec::new(),
136                };
137                for (name, value) in aobj {
138                    match name.as_str() {
139                        "weight" => {
140                            entry.weight = value.as_u64().filter(|w| *w >= 1).ok_or_else(|| {
141                                format!(
142                                    "rules[{idx}].actions[{aidx}].weight must be an integer >= 1"
143                                )
144                            })?;
145                        }
146                        // Discard warnings here — the compile-time walk (a
147                        // later task) reports well-formed-but-unknown
148                        // references; execution must not.
149                        "set_headers" => {
150                            entry.set_headers = parse_kv_object(
151                                value,
152                                &format!("rules[{idx}].actions[{aidx}].set_headers"),
153                            )?
154                            .into_iter()
155                            .map(|(k, v)| (k.to_lowercase(), Template::parse(&v).0))
156                            .collect();
157                        }
158                        "set_labels" => {
159                            entry.set_labels = parse_kv_object(
160                                value,
161                                &format!("rules[{idx}].actions[{aidx}].set_labels"),
162                            )?
163                            .into_iter()
164                            .map(|(k, v)| (k, Template::parse(&v).0))
165                            .collect();
166                        }
167                        other => {
168                            return Err(format!(
169                                "rules[{idx}].actions[{aidx}]: not supported action: {other}"
170                            ));
171                        }
172                    }
173                }
174                actions.push(entry);
175            }
176
177            let total_weight = actions.iter().map(|a| a.weight).sum();
178            rules.push(Rule {
179                matcher,
180                actions,
181                total_weight,
182                cursor: AtomicU64::new(0),
183            });
184        }
185
186        Ok(Self { rules })
187    }
188}
189
190impl Rule {
191    /// Picks an action by weighted round-robin: a shared cursor modulo the
192    /// total weight walks the cumulative weight buckets, so an action with
193    /// weight 3 is chosen 3 out of every `total_weight` calls.
194    fn pick_action(&self) -> &ActionEntry {
195        let slot = self.cursor.fetch_add(1, Ordering::Relaxed) % self.total_weight;
196        let mut acc = 0;
197        for action in &self.actions {
198            acc += action.weight;
199            if slot < acc {
200                return action;
201            }
202        }
203        // Unreachable: slot < total_weight == sum(weights).
204        self.actions.last().expect("actions is non-empty")
205    }
206}
207
208#[async_trait]
209impl Plugin for TrafficLabelPlugin {
210    fn plugin_type(&self) -> &str {
211        "traffic-label"
212    }
213
214    fn reads_response_body(&self) -> bool {
215        // The actions only write headers, but a matcher is an arbitrary
216        // condition and may read the response body -- `resp_body`, or a
217        // `response_body:$...` JSONPath. Such a rule silently stops matching
218        // on a streaming route, so it has to force buffering instead.
219        self.rules.iter().any(|rule| {
220            rule.matcher
221                .as_ref()
222                .is_some_and(|e| e.references_response_body())
223        })
224    }
225
226    async fn execute(&self, mut ctx: Context) -> PluginResult {
227        for rule in &self.rules {
228            let matched = rule.matcher.as_ref().is_none_or(|e| e.eval(&ctx));
229            if !matched {
230                continue;
231            }
232
233            let action = rule.pick_action();
234            if action.set_headers.is_empty() && action.set_labels.is_empty() {
235                // Weight-only entry: nothing to apply — fall through to the
236                // next rule (mirrors the APISIX plugin's behavior).
237                continue;
238            }
239
240            let headers: Vec<(String, String)> = action
241                .set_headers
242                .iter()
243                .map(|(name, tmpl)| (name.clone(), tmpl.render_with_legacy(&ctx)))
244                .collect();
245            let labels: Vec<(String, String)> = action
246                .set_labels
247                .iter()
248                .map(|(key, tmpl)| (key.clone(), tmpl.render_with_legacy(&ctx)))
249                .collect();
250
251            for (name, value) in headers {
252                ctx.request.headers.insert(name, vec![value]);
253            }
254            for (key, value) in labels {
255                ctx.message
256                    .insert(format!("label.{key}"), serde_json::Value::String(value));
257            }
258            break;
259        }
260
261        Ok(PluginOutput::success(ctx))
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
269    use bytes::Bytes;
270
271    fn test_ctx(channel: Option<&str>) -> Context {
272        let mut query = HashMap::new();
273        if let Some(c) = channel {
274            query.insert("channel".to_string(), vec![c.to_string()]);
275        }
276        Context {
277            request: GatewayRequest {
278                method: "GET".to_string(),
279                path: "/api".to_string(),
280                host: "example.com".to_string(),
281                scheme: "http".to_string(),
282                headers: HashMap::new(),
283                query_params: query,
284                body: Bytes::new(),
285                remote_addr: "10.1.2.3:44321".to_string(),
286                protocol: Protocol::Http1,
287            },
288            response: GatewayResponse {
289                status_code: 0,
290                headers: HashMap::new(),
291                body: Bytes::new(),
292                stream: None,
293            },
294            message: HashMap::new(),
295            errors: Vec::new(),
296        }
297    }
298
299    fn plugin(config: serde_json::Value) -> Result<TrafficLabelPlugin, String> {
300        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
301        TrafficLabelPlugin::from_config(&map)
302    }
303
304    #[tokio::test]
305    async fn test_match_sets_headers_with_interpolation() {
306        let p = plugin(serde_json::json!({
307            "rules": [{
308                "match": [["arg_channel", "==", "beta"]],
309                "actions": [{
310                    "set_headers": { "X-Server-Id": "beta", "x-origin": "$remote_addr" }
311                }]
312            }]
313        }))
314        .unwrap();
315
316        let out = p.execute(test_ctx(Some("beta"))).await.unwrap();
317        let ctx = out.context;
318        assert_eq!(
319            ctx.request.headers.get("x-server-id"),
320            Some(&vec!["beta".to_string()])
321        );
322        assert_eq!(
323            ctx.request.headers.get("x-origin"),
324            Some(&vec!["10.1.2.3".to_string()])
325        );
326
327        // Non-matching request passes through untouched.
328        let out = p.execute(test_ctx(Some("stable"))).await.unwrap();
329        assert!(out.context.request.headers.is_empty());
330    }
331
332    #[tokio::test]
333    async fn test_set_labels_writes_message_keys() {
334        let p = plugin(serde_json::json!({
335            "rules": [{
336                "actions": [{ "set_labels": { "tier": "beta", "path": "$uri" } }]
337            }]
338        }))
339        .unwrap();
340        let out = p.execute(test_ctx(None)).await.unwrap();
341        assert_eq!(
342            out.context.message.get("label.tier"),
343            Some(&serde_json::json!("beta"))
344        );
345        assert_eq!(
346            out.context.message.get("label.path"),
347            Some(&serde_json::json!("/api"))
348        );
349    }
350
351    #[tokio::test]
352    async fn test_weighted_round_robin_between_actions() {
353        let p = plugin(serde_json::json!({
354            "rules": [{
355                "actions": [
356                    { "set_headers": { "x-variant": "a" }, "weight": 3 },
357                    { "set_headers": { "x-variant": "b" }, "weight": 1 }
358                ]
359            }]
360        }))
361        .unwrap();
362
363        let mut counts: HashMap<String, u32> = HashMap::new();
364        for _ in 0..8 {
365            let out = p.execute(test_ctx(None)).await.unwrap();
366            let v = out.context.request.headers.get("x-variant").unwrap()[0].clone();
367            *counts.entry(v).or_insert(0) += 1;
368        }
369        assert_eq!(counts.get("a"), Some(&6));
370        assert_eq!(counts.get("b"), Some(&2));
371    }
372
373    #[tokio::test]
374    async fn test_weight_only_action_falls_through_to_next_rule() {
375        let p = plugin(serde_json::json!({
376            "rules": [
377                { "actions": [{ "weight": 1 }] },
378                { "actions": [{ "set_headers": { "x-fallback": "yes" } }] }
379            ]
380        }))
381        .unwrap();
382        let out = p.execute(test_ctx(None)).await.unwrap();
383        assert_eq!(
384            out.context.request.headers.get("x-fallback"),
385            Some(&vec!["yes".to_string()])
386        );
387    }
388
389    #[tokio::test]
390    async fn test_first_matching_rule_wins() {
391        let p = plugin(serde_json::json!({
392            "rules": [
393                { "actions": [{ "set_labels": { "rule": "first" } }] },
394                { "actions": [{ "set_labels": { "rule": "second" } }] }
395            ]
396        }))
397        .unwrap();
398        let out = p.execute(test_ctx(None)).await.unwrap();
399        assert_eq!(
400            out.context.message.get("label.rule"),
401            Some(&serde_json::json!("first"))
402        );
403    }
404
405    #[test]
406    fn test_config_errors() {
407        // No rules.
408        assert!(plugin(serde_json::json!({})).is_err());
409        assert!(plugin(serde_json::json!({ "rules": [] })).is_err());
410        // Missing actions.
411        assert!(plugin(serde_json::json!({ "rules": [{}] })).is_err());
412        // Unsupported action key.
413        assert!(plugin(serde_json::json!({
414            "rules": [{ "actions": [{ "redirect": { "uri": "/x" } }] }]
415        }))
416        .is_err());
417        // Invalid match expression surfaces at load.
418        assert!(plugin(serde_json::json!({
419            "rules": [{
420                "match": [["uri", "bogus", "/x"]],
421                "actions": [{ "set_headers": { "x": "y" } }]
422            }]
423        }))
424        .is_err());
425        // Invalid weight.
426        assert!(plugin(serde_json::json!({
427            "rules": [{ "actions": [{ "set_headers": { "x": "y" }, "weight": 0 }] }]
428        }))
429        .is_err());
430    }
431
432    /// A matcher on the response body makes this node a body reader even
433    /// though nothing else in its config touches the body. Reporting `false`
434    /// unconditionally let a streaming route silently stop matching: empty
435    /// body, no error, no log, no `buffering` entry.
436    #[test]
437    fn test_traffic_label_matching_on_the_response_body_forces_buffering() {
438        let reads = plugin(serde_json::json!({
439            "rules": [{
440                "match": [["resp_body", "~~", "error"]],
441                "actions": [{ "set_headers": { "x-failed": "1" } }]
442            }]
443        }))
444        .unwrap();
445        assert!(reads.reads_response_body());
446    }
447
448    /// A request-side matcher leaves the response body untouched, so the node
449    /// must stay stream-safe -- over-reporting would buffer ordinary policies.
450    #[test]
451    fn test_traffic_label_matching_on_the_request_stays_stream_safe() {
452        let safe = plugin(serde_json::json!({
453            "rules": [{
454                "match": [["arg_channel", "==", "beta"]],
455                "actions": [{ "set_headers": { "x-server-id": "beta" } }]
456            }]
457        }))
458        .unwrap();
459        assert!(!safe.reads_response_body());
460    }
461}