featherbit/plugins/native/
workflow.rs1use async_trait::async_trait;
18use bytes::Bytes;
19use std::collections::HashMap;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::Arc;
22use std::time::Duration;
23
24use crate::context::{Context, GatewayError};
25use crate::plugins::resources::PluginResources;
26use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
27use crate::ratelimit::CounterStore;
28use crate::vars::{interpolate, Expr};
29
30static INSTANCE_SEQ: AtomicU64 = AtomicU64::new(0);
34
35pub struct WorkflowPlugin {
39 rules: Vec<Rule>,
40}
41
42struct Rule {
43 case: Option<Expr>,
45 action: Action,
46}
47
48enum Action {
49 Return { code: u16 },
51 LimitCount(LimitCount),
53}
54
55struct LimitCount {
56 count: u64,
57 time_window: Duration,
58 key_template: String,
60 rejected_code: u16,
61 rejected_msg: Option<String>,
62 counter_prefix: String,
64 store: Arc<dyn CounterStore>,
65}
66
67impl WorkflowPlugin {
68 pub fn from_config(
103 config: &HashMap<String, serde_json::Value>,
104 resources: &Arc<PluginResources>,
105 ) -> Result<Self, String> {
106 let raw_rules = config
107 .get("rules")
108 .and_then(|v| v.as_array())
109 .ok_or("workflow requires a 'rules' array")?;
110 if raw_rules.is_empty() {
111 return Err("workflow 'rules' must not be empty".to_string());
112 }
113
114 let instance = INSTANCE_SEQ.fetch_add(1, Ordering::Relaxed);
115 let mut rules = Vec::with_capacity(raw_rules.len());
116
117 for (idx, raw) in raw_rules.iter().enumerate() {
118 let obj = raw
119 .as_object()
120 .ok_or_else(|| format!("rules[{idx}] must be an object"))?;
121
122 let case = match obj.get("case") {
123 None => None,
124 Some(v) => Some(Expr::parse(v).map_err(|e| format!("rules[{idx}].case: {e}"))?),
125 };
126
127 let actions = obj
128 .get("actions")
129 .and_then(|v| v.as_array())
130 .filter(|a| !a.is_empty())
131 .ok_or_else(|| format!("rules[{idx}] requires a non-empty 'actions' array"))?;
132
133 let action_arr = actions[0]
135 .as_array()
136 .filter(|a| !a.is_empty())
137 .ok_or_else(|| format!("rules[{idx}].actions[0] must be a non-empty array"))?;
138 let name = action_arr[0]
139 .as_str()
140 .ok_or_else(|| format!("rules[{idx}]: action name must be a string"))?;
141 let empty = serde_json::Map::new();
142 let params = action_arr
143 .get(1)
144 .map(|v| {
145 v.as_object().ok_or_else(|| {
146 format!("rules[{idx}]: '{name}' action params must be an object")
147 })
148 })
149 .transpose()?
150 .unwrap_or(&empty);
151
152 let action = match name {
153 "return" => {
154 let code = params
155 .get("code")
156 .and_then(|v| v.as_u64())
157 .filter(|c| (100..=599).contains(c))
158 .ok_or_else(|| {
159 format!("rules[{idx}]: 'return' action requires 'code' (100-599)")
160 })? as u16;
161 Action::Return { code }
162 }
163 "limit-count" => {
164 let count = params
165 .get("count")
166 .and_then(|v| v.as_u64())
167 .filter(|c| *c > 0)
168 .ok_or_else(|| {
169 format!("rules[{idx}]: 'limit-count' requires 'count' (integer > 0)")
170 })?;
171 let time_window = params
172 .get("time_window")
173 .and_then(|v| v.as_f64())
174 .filter(|w| *w > 0.0 && w.is_finite())
175 .ok_or_else(|| {
176 format!(
177 "rules[{idx}]: 'limit-count' requires 'time_window' (seconds > 0)"
178 )
179 })?;
180 let key_template = params
181 .get("key")
182 .map(|v| {
183 v.as_str()
184 .map(String::from)
185 .ok_or_else(|| format!("rules[{idx}]: 'key' must be a string"))
186 })
187 .transpose()?
188 .unwrap_or_else(|| "$remote_addr".to_string());
189 let rejected_code =
190 params
191 .get("rejected_code")
192 .map(|v| {
193 v.as_u64().filter(|c| (200..=599).contains(c)).ok_or_else(|| {
194 format!("rules[{idx}]: 'rejected_code' must be an integer 200-599")
195 })
196 })
197 .transpose()?
198 .unwrap_or(503) as u16;
199 let rejected_msg = params
200 .get("rejected_msg")
201 .map(|v| {
202 v.as_str().map(String::from).ok_or_else(|| {
203 format!("rules[{idx}]: 'rejected_msg' must be a string")
204 })
205 })
206 .transpose()?;
207 let policy = params
208 .get("policy")
209 .and_then(|v| v.as_str())
210 .unwrap_or("local");
211 let store = resources.counters.get(policy)?;
212
213 Action::LimitCount(LimitCount {
214 count,
215 time_window: Duration::from_secs_f64(time_window),
216 key_template,
217 rejected_code,
218 rejected_msg,
219 counter_prefix: format!("workflow:{instance}:{idx}"),
220 store,
221 })
222 }
223 other => {
224 return Err(format!(
225 "rules[{idx}]: unsupported action: {other} — supported: return, limit-count"
226 ));
227 }
228 };
229
230 rules.push(Rule { case, action });
231 }
232
233 Ok(Self { rules })
234 }
235
236 fn reject(
239 mut ctx: Context,
240 status: u16,
241 body: Bytes,
242 code: &str,
243 message: &str,
244 ) -> PluginResult {
245 ctx.response.status_code = status;
246 ctx.response.body = body;
247 ctx.response.headers.insert(
248 "content-type".to_string(),
249 vec!["application/json".to_string()],
250 );
251 Err(PluginExecutionError {
252 context: ctx,
253 error: GatewayError {
254 node_id: String::new(),
255 code: code.to_string(),
256 message: message.to_string(),
257 metadata: HashMap::new(),
258 },
259 })
260 }
261}
262
263fn set_quota_headers(ctx: &mut Context, limit: u64, remaining: u64, reset: Duration) {
266 ctx.response
267 .headers
268 .insert("x-ratelimit-limit".to_string(), vec![limit.to_string()]);
269 ctx.response.headers.insert(
270 "x-ratelimit-remaining".to_string(),
271 vec![remaining.to_string()],
272 );
273 ctx.response.headers.insert(
274 "x-ratelimit-reset".to_string(),
275 vec![reset.as_secs().to_string()],
276 );
277}
278
279#[async_trait]
280impl Plugin for WorkflowPlugin {
281 fn plugin_type(&self) -> &str {
282 "workflow"
283 }
284
285 async fn execute(
286 &self,
287 mut ctx: Context,
288 _named_inputs: &HashMap<String, serde_json::Value>,
289 ) -> PluginResult {
290 for rule in &self.rules {
291 let matched = rule.case.as_ref().is_none_or(|e| e.eval(&ctx));
292 if !matched {
293 continue;
294 }
295
296 match &rule.action {
298 Action::Return { code } => {
299 return Self::reject(
300 ctx,
301 *code,
302 Bytes::from_static(br#"{"error_msg":"rejected by workflow"}"#),
303 "WORKFLOW_REJECTED",
304 "rejected by workflow",
305 );
306 }
307 Action::LimitCount(lc) => {
308 let key = format!(
309 "{}:{}",
310 lc.counter_prefix,
311 interpolate(&ctx, &lc.key_template)
312 );
313 let window = match lc
314 .store
315 .incr_fixed_window(&key, lc.count, lc.time_window)
316 .await
317 {
318 Ok(w) => w,
319 Err(e) => {
320 return Self::reject(
322 ctx,
323 500,
324 Bytes::from_static(br#"{"error_msg":"rate limit backend error"}"#),
325 "RATE_LIMIT_ERROR",
326 &e.to_string(),
327 );
328 }
329 };
330
331 set_quota_headers(&mut ctx, window.limit, window.remaining, window.reset);
332
333 if window.allowed {
334 return Ok(PluginOutput {
335 context: ctx,
336 named_outputs: HashMap::new(),
337 });
338 }
339 let body = match &lc.rejected_msg {
340 Some(msg) => {
341 Bytes::from(serde_json::json!({ "error_msg": msg }).to_string())
342 }
343 None => Bytes::new(),
344 };
345 return Self::reject(
346 ctx,
347 lc.rejected_code,
348 body,
349 "RATE_LIMITED",
350 "rejected by workflow limit-count",
351 );
352 }
353 }
354 }
355
356 Ok(PluginOutput {
358 context: ctx,
359 named_outputs: HashMap::new(),
360 })
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
368
369 fn test_ctx(path: &str) -> Context {
370 Context {
371 request: GatewayRequest {
372 method: "GET".to_string(),
373 path: path.to_string(),
374 host: "example.com".to_string(),
375 scheme: "http".to_string(),
376 headers: HashMap::new(),
377 query_params: HashMap::new(),
378 body: Bytes::new(),
379 remote_addr: "10.1.2.3:44321".to_string(),
380 protocol: Protocol::Http1,
381 },
382 response: GatewayResponse {
383 status_code: 0,
384 headers: HashMap::new(),
385 body: Bytes::new(),
386 },
387 message: HashMap::new(),
388 errors: Vec::new(),
389 }
390 }
391
392 fn plugin(config: serde_json::Value) -> Result<WorkflowPlugin, String> {
393 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
394 WorkflowPlugin::from_config(&map, &PluginResources::empty())
395 }
396
397 #[tokio::test]
398 async fn test_return_action_rejects_matching_request() {
399 let p = plugin(serde_json::json!({
400 "rules": [{
401 "case": [["uri", "~~", "^/admin"]],
402 "actions": [["return", { "code": 403 }]]
403 }]
404 }))
405 .unwrap();
406
407 let err = p
408 .execute(test_ctx("/admin/users"), &HashMap::new())
409 .await
410 .unwrap_err();
411 assert_eq!(err.error.code, "WORKFLOW_REJECTED");
412 assert_eq!(err.context.response.status_code, 403);
413 assert_eq!(
414 err.context.response.body,
415 Bytes::from_static(br#"{"error_msg":"rejected by workflow"}"#)
416 );
417
418 let out = p
420 .execute(test_ctx("/public"), &HashMap::new())
421 .await
422 .unwrap();
423 assert_eq!(out.context.response.status_code, 0);
424 }
425
426 #[tokio::test]
427 async fn test_rule_without_case_matches_all() {
428 let p = plugin(serde_json::json!({
429 "rules": [{ "actions": [["return", { "code": 418 }]] }]
430 }))
431 .unwrap();
432 let err = p
433 .execute(test_ctx("/anything"), &HashMap::new())
434 .await
435 .unwrap_err();
436 assert_eq!(err.context.response.status_code, 418);
437 }
438
439 #[tokio::test]
440 async fn test_first_matching_rule_wins() {
441 let p = plugin(serde_json::json!({
442 "rules": [
443 { "case": [["uri", "==", "/both"]], "actions": [["return", { "code": 401 }]] },
444 { "actions": [["return", { "code": 403 }]] }
445 ]
446 }))
447 .unwrap();
448 let err = p
449 .execute(test_ctx("/both"), &HashMap::new())
450 .await
451 .unwrap_err();
452 assert_eq!(err.context.response.status_code, 401);
453 let err = p
454 .execute(test_ctx("/other"), &HashMap::new())
455 .await
456 .unwrap_err();
457 assert_eq!(err.context.response.status_code, 403);
458 }
459
460 #[tokio::test]
461 async fn test_limit_count_allows_then_rejects() {
462 let p = plugin(serde_json::json!({
463 "rules": [{
464 "actions": [["limit-count", {
465 "count": 2,
466 "time_window": 60,
467 "rejected_msg": "over quota"
468 }]]
469 }]
470 }))
471 .unwrap();
472
473 for i in 0..2 {
474 let out = p
475 .execute(test_ctx("/x"), &HashMap::new())
476 .await
477 .unwrap_or_else(|_| panic!("request {i} should pass"));
478 assert_eq!(
479 out.context.response.headers.get("x-ratelimit-limit"),
480 Some(&vec!["2".to_string()])
481 );
482 }
483
484 let err = p
485 .execute(test_ctx("/x"), &HashMap::new())
486 .await
487 .unwrap_err();
488 assert_eq!(err.error.code, "RATE_LIMITED");
489 assert_eq!(err.context.response.status_code, 503);
490 assert_eq!(
491 err.context.response.body,
492 Bytes::from(r#"{"error_msg":"over quota"}"#)
493 );
494 assert_eq!(
495 err.context.response.headers.get("x-ratelimit-remaining"),
496 Some(&vec!["0".to_string()])
497 );
498 }
499
500 #[tokio::test]
501 async fn test_limit_count_key_isolates_clients() {
502 let p = plugin(serde_json::json!({
503 "rules": [{
504 "actions": [["limit-count", { "count": 1, "time_window": 60 }]]
505 }]
506 }))
507 .unwrap();
508
509 assert!(p.execute(test_ctx("/x"), &HashMap::new()).await.is_ok());
510 assert!(p.execute(test_ctx("/x"), &HashMap::new()).await.is_err());
511
512 let mut other = test_ctx("/x");
514 other.request.remote_addr = "192.168.9.9:1".to_string();
515 assert!(p.execute(other, &HashMap::new()).await.is_ok());
516 }
517
518 #[test]
519 fn test_config_errors() {
520 assert!(plugin(serde_json::json!({})).is_err());
522 assert!(plugin(serde_json::json!({ "rules": [] })).is_err());
523 assert!(plugin(serde_json::json!({
525 "rules": [{ "actions": [["redirect", { "uri": "/x" }]] }]
526 }))
527 .is_err());
528 assert!(plugin(serde_json::json!({
530 "rules": [{ "actions": [["return", {}]] }]
531 }))
532 .is_err());
533 assert!(plugin(serde_json::json!({
535 "rules": [{ "actions": [["limit-count", { "count": 5 }]] }]
536 }))
537 .is_err());
538 assert!(plugin(serde_json::json!({
540 "rules": [{
541 "case": [["uri", "bogus", "/x"]],
542 "actions": [["return", { "code": 403 }]]
543 }]
544 }))
545 .is_err());
546 assert!(plugin(serde_json::json!({
548 "rules": [{
549 "actions": [["limit-count", { "count": 1, "time_window": 1, "policy": "redis" }]]
550 }]
551 }))
552 .is_err());
553 }
554}