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::template::Template;
21use crate::vars::Expr;
22
23pub struct TrafficLabelPlugin {
27 rules: Vec<Rule>,
28}
29
30struct Rule {
31 matcher: Option<Expr>,
33 actions: Vec<ActionEntry>,
34 total_weight: u64,
35 cursor: AtomicU64,
37}
38
39struct ActionEntry {
40 weight: u64,
41 set_headers: Vec<(String, Template)>,
43 set_labels: Vec<(String, Template)>,
45}
46
47fn 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 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 "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 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 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 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 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 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 assert!(plugin(serde_json::json!({})).is_err());
409 assert!(plugin(serde_json::json!({ "rules": [] })).is_err());
410 assert!(plugin(serde_json::json!({ "rules": [{}] })).is_err());
412 assert!(plugin(serde_json::json!({
414 "rules": [{ "actions": [{ "redirect": { "uri": "/x" } }] }]
415 }))
416 .is_err());
417 assert!(plugin(serde_json::json!({
419 "rules": [{
420 "match": [["uri", "bogus", "/x"]],
421 "actions": [{ "set_headers": { "x": "y" } }]
422 }]
423 }))
424 .is_err());
425 assert!(plugin(serde_json::json!({
427 "rules": [{ "actions": [{ "set_headers": { "x": "y" }, "weight": 0 }] }]
428 }))
429 .is_err());
430 }
431
432 #[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 #[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}