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