1use async_trait::async_trait;
11use regex::Regex;
12use serde_json_path::JsonPath;
13use std::collections::{HashMap, HashSet};
14
15use crate::context::Context;
16use crate::plugins::{Plugin, PluginOutput, PluginResult};
17use crate::vars::template::Template;
18
19pub struct SetVarsPlugin {
23 vars: Vec<VarRule>,
24}
25
26impl std::fmt::Debug for SetVarsPlugin {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.debug_struct("SetVarsPlugin")
29 .field(
30 "vars",
31 &self.vars.iter().map(|v| &v.name).collect::<Vec<_>>(),
32 )
33 .finish()
34 }
35}
36
37enum Group {
39 Index(usize),
41 Name(String),
42}
43
44struct VarRule {
45 name: String,
46 from: Template,
49 json_path: Option<JsonPath>,
51 regex: Option<(Regex, Group)>,
53 default: Option<String>,
56}
57
58const ALLOWED_KEYS: &[&str] = &["name", "from", "json_path", "regex", "group", "default"];
59
60fn str_field<'a>(
61 obj: &'a serde_json::Map<String, serde_json::Value>,
62 key: &str,
63 at: &str,
64) -> Result<Option<&'a str>, String> {
65 match obj.get(key) {
66 None | Some(serde_json::Value::Null) => Ok(None),
67 Some(serde_json::Value::String(s)) => Ok(Some(s.as_str())),
68 Some(_) => Err(format!("{at}.{key} must be a string")),
69 }
70}
71
72impl SetVarsPlugin {
73 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
99 let raw = config
100 .get("vars")
101 .ok_or("set-vars requires a 'vars' array")?
102 .as_array()
103 .filter(|a| !a.is_empty())
104 .ok_or("set-vars requires a non-empty 'vars' array")?;
105
106 let mut seen = HashSet::new();
107 let mut vars = Vec::with_capacity(raw.len());
108 for (idx, entry) in raw.iter().enumerate() {
109 let at = format!("vars[{idx}]");
110 let obj = entry
111 .as_object()
112 .ok_or_else(|| format!("{at} must be an object"))?;
113 if let Some(k) = obj.keys().find(|k| !ALLOWED_KEYS.contains(&k.as_str())) {
114 return Err(format!("{at}: unknown key '{k}'"));
115 }
116
117 let name = str_field(obj, "name", &at)?
118 .filter(|n| !n.is_empty())
119 .ok_or_else(|| format!("{at}.name is required"))?;
120 if !name
121 .chars()
122 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
123 {
124 return Err(format!(
125 "{at}.name '{name}' may only contain letters, digits, '_', '-' and '.'"
126 ));
127 }
128 if !seen.insert(name.to_string()) {
129 return Err(format!("{at}: duplicate name '{name}'"));
130 }
131
132 let json_path = match str_field(obj, "json_path", &at)? {
133 None => None,
134 Some(p) => Some(
135 JsonPath::parse(p)
136 .map_err(|e| format!("{at}.json_path: invalid JSONPath '{p}': {e}"))?,
137 ),
138 };
139 let from_src = match (str_field(obj, "from", &at)?, json_path.is_some()) {
140 (Some(s), _) => s.to_string(),
141 (None, true) => "$request_body".to_string(),
142 (None, false) => {
143 return Err(format!(
144 "{at}: 'from' is required unless 'json_path' is set"
145 ))
146 }
147 };
148 let (from, _warnings) = Template::parse(&from_src);
149
150 let regex = match str_field(obj, "regex", &at)? {
151 None => {
152 if obj.contains_key("group") {
153 return Err(format!("{at}.group needs a 'regex'"));
154 }
155 None
156 }
157 Some(pattern) => {
158 let re = Regex::new(pattern)
159 .map_err(|e| format!("{at}.regex: invalid regex: {e}"))?;
160 let group = match obj.get("group") {
161 None => Group::Index(1.min(re.captures_len() - 1)),
162 Some(serde_json::Value::Number(n)) => {
163 let i = n.as_u64().ok_or_else(|| {
164 format!("{at}.group must be a non-negative integer")
165 })? as usize;
166 if i >= re.captures_len() {
167 return Err(format!(
168 "{at}.group {i} is out of range: the regex has {} capture group(s)",
169 re.captures_len() - 1
170 ));
171 }
172 Group::Index(i)
173 }
174 Some(serde_json::Value::String(g)) if g.parse::<usize>().is_ok() => {
176 let i: usize = g.parse().unwrap_or_default();
177 if i >= re.captures_len() {
178 return Err(format!(
179 "{at}.group {i} is out of range: the regex has {} capture group(s)",
180 re.captures_len() - 1
181 ));
182 }
183 Group::Index(i)
184 }
185 Some(serde_json::Value::String(g)) => {
186 if !re.capture_names().any(|n| n == Some(g.as_str())) {
187 return Err(format!(
188 "{at}.group '{g}' is not a named capture group of the regex"
189 ));
190 }
191 Group::Name(g.clone())
192 }
193 Some(_) => {
194 return Err(format!(
195 "{at}.group must be an integer or a capture-group name"
196 ))
197 }
198 };
199 Some((re, group))
200 }
201 };
202
203 let default = str_field(obj, "default", &at)?.map(str::to_string);
204
205 vars.push(VarRule {
206 name: name.to_string(),
207 from,
208 json_path,
209 regex,
210 default,
211 });
212 }
213 Ok(Self { vars })
214 }
215}
216
217fn json_nodes_text(nodes: &[&serde_json::Value]) -> Option<String> {
220 match nodes {
221 [] => None,
222 [serde_json::Value::String(s)] => Some(s.clone()),
223 [one] => Some(one.to_string()),
224 many => {
225 Some(serde_json::Value::Array(many.iter().map(|v| (*v).clone()).collect()).to_string())
226 }
227 }
228}
229
230impl VarRule {
231 fn evaluate(&self, ctx: &Context) -> Option<String> {
232 let mut value = self.from.render_with_legacy(ctx);
233 if let Some(path) = &self.json_path {
234 let doc: serde_json::Value = serde_json::from_str(&value).ok()?;
235 value = json_nodes_text(&path.query(&doc).all())?;
236 }
237 if let Some((re, group)) = &self.regex {
238 let caps = re.captures(&value)?;
239 let m = match group {
240 Group::Index(i) => caps.get(*i),
241 Group::Name(n) => caps.name(n),
242 }?;
243 value = m.as_str().to_string();
244 }
245 Some(value)
246 }
247}
248
249#[async_trait]
250impl Plugin for SetVarsPlugin {
251 fn plugin_type(&self) -> &str {
252 "set-vars"
253 }
254
255 async fn execute(&self, mut ctx: Context) -> PluginResult {
256 for rule in &self.vars {
257 let value = rule
258 .evaluate(&ctx)
259 .filter(|v| !v.is_empty())
260 .or_else(|| rule.default.clone())
261 .unwrap_or_default();
262 ctx.message
263 .insert(rule.name.clone(), serde_json::Value::String(value));
264 }
265 Ok(PluginOutput::success(ctx))
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
273 use bytes::Bytes;
274 use std::collections::HashMap;
275
276 fn ctx(path: &str, headers: &[(&str, &str)], query: &[(&str, &str)], body: &str) -> Context {
277 Context {
278 request: GatewayRequest {
279 method: "GET".to_string(),
280 path: path.to_string(),
281 host: "example.com".to_string(),
282 scheme: "http".to_string(),
283 headers: headers
284 .iter()
285 .map(|(k, v)| (k.to_string(), vec![v.to_string()]))
286 .collect(),
287 query_params: query
288 .iter()
289 .map(|(k, v)| (k.to_string(), vec![v.to_string()]))
290 .collect(),
291 body: Bytes::from(body.to_string()),
292 remote_addr: "10.1.2.3:44321".to_string(),
293 protocol: Protocol::Http1,
294 },
295 response: GatewayResponse {
296 status_code: 0,
297 headers: HashMap::new(),
298 body: Bytes::new(),
299 stream: None,
300 },
301 message: HashMap::new(),
302 errors: Vec::new(),
303 }
304 }
305
306 fn plugin(config: serde_json::Value) -> Result<SetVarsPlugin, String> {
307 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
308 SetVarsPlugin::from_config(&map)
309 }
310
311 async fn run(config: serde_json::Value, c: Context) -> Context {
312 let out = plugin(config).unwrap().execute(c).await.unwrap();
313 assert!(out.port.is_none(), "always the success port");
314 out.context
315 }
316
317 fn msg<'a>(c: &'a Context, key: &str) -> Option<&'a str> {
318 c.message.get(key).and_then(|v| v.as_str())
319 }
320
321 #[tokio::test]
322 async fn test_path_segment_via_regex_capture() {
323 let out = run(
324 serde_json::json!({"vars": [
325 {"name": "user", "from": "$uri", "regex": "^/hello/([^/]+)"}
326 ]}),
327 ctx("/hello/frenk", &[], &[], ""),
328 )
329 .await;
330 assert_eq!(msg(&out, "user"), Some("frenk"));
331 assert_eq!(
333 crate::vars::interpolate(&out, "hello $msg_user"),
334 "hello frenk"
335 );
336 }
337
338 #[tokio::test]
339 async fn test_plain_copy_header_and_template_source() {
340 let out = run(
341 serde_json::json!({"vars": [
342 {"name": "tenant", "from": "$http_x_tenant"},
343 {"name": "where", "from": "{{request.method}} {{request.path}}"}
344 ]}),
345 ctx("/x", &[("x-tenant", "acme")], &[], ""),
346 )
347 .await;
348 assert_eq!(msg(&out, "tenant"), Some("acme"));
349 assert_eq!(msg(&out, "where"), Some("GET /x"));
350 }
351
352 #[tokio::test]
353 async fn test_query_param_with_default_when_absent_or_no_match() {
354 let out = run(
355 serde_json::json!({"vars": [
356 {"name": "plan", "from": "$arg_plan", "default": "free"},
357 {"name": "user", "from": "$uri", "regex": "^/hello/([^/]+)", "default": "stranger"},
358 {"name": "empty", "from": "$uri", "regex": "^/nope/(.*)"}
359 ]}),
360 ctx("/hello/", &[], &[], ""),
361 )
362 .await;
363 assert_eq!(msg(&out, "plan"), Some("free"));
364 assert_eq!(msg(&out, "user"), Some("stranger"));
365 assert_eq!(
366 msg(&out, "empty"),
367 Some(""),
368 "no match and no default → empty string"
369 );
370 }
371
372 #[tokio::test]
373 async fn test_json_path_on_request_body() {
374 let body = r#"{"order":{"id":42,"ok":true,"tags":["a","b"],"ship":{"city":"Turin"}}}"#;
375 let out = run(
376 serde_json::json!({"vars": [
377 {"name": "order_id", "json_path": "$.order.id"},
378 {"name": "ok", "json_path": "$.order.ok"},
379 {"name": "tags", "json_path": "$.order.tags[*]"},
380 {"name": "ship", "json_path": "$.order.ship"},
381 {"name": "missing", "json_path": "$.order.none", "default": "n/a"},
382 {"name": "city_upper", "json_path": "$.order.ship.city", "regex": "^(T[a-z]+)"}
383 ]}),
384 ctx("/orders", &[], &[], body),
385 )
386 .await;
387 assert_eq!(msg(&out, "order_id"), Some("42"));
388 assert_eq!(msg(&out, "ok"), Some("true"));
389 assert_eq!(
390 msg(&out, "tags"),
391 Some(r#"["a","b"]"#),
392 "several nodes → JSON array text"
393 );
394 assert_eq!(
395 msg(&out, "ship"),
396 Some(r#"{"city":"Turin"}"#),
397 "object → JSON text"
398 );
399 assert_eq!(msg(&out, "missing"), Some("n/a"));
400 assert_eq!(msg(&out, "city_upper"), Some("Turin"));
401 }
402
403 #[tokio::test]
404 async fn test_json_path_on_an_explicit_source_and_non_json_input() {
405 let out = run(
406 serde_json::json!({"vars": [
407 {"name": "sub", "from": "$http_x_claims", "json_path": "$.sub"},
408 {"name": "bad", "from": "$uri", "json_path": "$.a", "default": "fallback"}
409 ]}),
410 ctx("/x", &[("x-claims", r#"{"sub":"alice"}"#)], &[], "not json"),
411 )
412 .await;
413 assert_eq!(msg(&out, "sub"), Some("alice"));
414 assert_eq!(
415 msg(&out, "bad"),
416 Some("fallback"),
417 "non-JSON source → default"
418 );
419 }
420
421 #[tokio::test]
422 async fn test_regex_groups_by_index_name_and_whole_match() {
423 let out = run(
424 serde_json::json!({"vars": [
425 {"name": "minor", "from": "$http_x_version", "regex": r"^(?P<major>\d+)\.(?P<minor>\d+)", "group": "minor"},
426 {"name": "major", "from": "$http_x_version", "regex": r"^(\d+)\.(\d+)", "group": 1},
427 {"name": "whole", "from": "$http_x_version", "regex": r"\d+\.\d+", "group": 0},
428 {"name": "minor_text", "from": "$http_x_version", "regex": r"^(\d+)\.(\d+)", "group": "2"}
429 ]}),
430 ctx("/x", &[("x-version", "3.14.1")], &[], ""),
431 )
432 .await;
433 assert_eq!(msg(&out, "minor"), Some("14"));
434 assert_eq!(msg(&out, "major"), Some("3"));
435 assert_eq!(msg(&out, "whole"), Some("3.14"));
436 assert_eq!(
437 msg(&out, "minor_text"),
438 Some("14"),
439 "a numeric string group is an index (UI form)"
440 );
441 }
442
443 #[tokio::test]
444 async fn test_later_vars_can_read_earlier_ones() {
445 let out = run(
446 serde_json::json!({"vars": [
447 {"name": "user", "from": "$uri", "regex": "^/hello/([^/]+)"},
448 {"name": "greeting", "from": "hello $msg_user"}
449 ]}),
450 ctx("/hello/frenk", &[], &[], ""),
451 )
452 .await;
453 assert_eq!(msg(&out, "greeting"), Some("hello frenk"));
454 }
455
456 #[test]
457 fn test_config_errors() {
458 let err = |c: serde_json::Value| plugin(c).unwrap_err();
459 assert!(err(serde_json::json!({})).contains("vars"));
460 assert!(err(serde_json::json!({"vars": []})).contains("non-empty"));
461 assert!(err(serde_json::json!({"vars": [{"from": "$uri"}]})).contains("name"));
462 assert!(
463 err(serde_json::json!({"vars": [{"name": "a b", "from": "$uri"}]})).contains("name")
464 );
465 assert!(err(serde_json::json!({"vars": [{"name": "x"}]})).contains("from"));
466 assert!(
467 err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "regex": "("}]}))
468 .contains("regex")
469 );
470 assert!(err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "regex": "^(a)", "group": 2}]}))
471 .contains("group"));
472 assert!(err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "regex": "^(a)", "group": "nope"}]}))
473 .contains("group"));
474 assert!(
475 err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "group": 1}]}))
476 .contains("group")
477 );
478 assert!(
479 err(serde_json::json!({"vars": [{"name": "x", "json_path": "$["}]}))
480 .contains("JSONPath")
481 );
482 assert!(
483 err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "bogus": 1}]}))
484 .contains("bogus")
485 );
486 assert!(err(serde_json::json!({"vars": [{"name": "x", "from": "$uri"}, {"name": "x", "from": "$uri"}]}))
487 .contains("duplicate"));
488 }
489}