featherbit/plugins/native/
traffic_label.rs1use 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
22pub struct TrafficLabelPlugin {
25 rules: Vec<Rule>,
26}
27
28struct Rule {
29 matcher: Option<Expr>,
31 actions: Vec<ActionEntry>,
32 total_weight: u64,
33 cursor: AtomicU64,
35}
36
37struct ActionEntry {
38 weight: u64,
39 set_headers: Vec<(String, String)>,
41 set_labels: Vec<(String, String)>,
43}
44
45fn 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 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 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 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 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 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 assert!(plugin(serde_json::json!({})).is_err());
399 assert!(plugin(serde_json::json!({ "rules": [] })).is_err());
400 assert!(plugin(serde_json::json!({ "rules": [{}] })).is_err());
402 assert!(plugin(serde_json::json!({
404 "rules": [{ "actions": [{ "redirect": { "uri": "/x" } }] }]
405 }))
406 .is_err());
407 assert!(plugin(serde_json::json!({
409 "rules": [{
410 "match": [["uri", "bogus", "/x"]],
411 "actions": [{ "set_headers": { "x": "y" } }]
412 }]
413 }))
414 .is_err());
415 assert!(plugin(serde_json::json!({
417 "rules": [{ "actions": [{ "set_headers": { "x": "y" }, "weight": 0 }] }]
418 }))
419 .is_err());
420 }
421}