1use std::borrow::Cow;
30
31use crate::context::Context;
32use crate::vars::{cookie_value, message_str, split_remote_addr};
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum TemplateRef {
37 RequestMethod,
38 RequestPath,
39 RequestHost,
40 RequestScheme,
41 RequestBody,
42 RequestHeader(String),
43 RequestQuery(String),
44 RequestCookie(String),
45 ResponseStatus,
46 ResponseBody,
47 ResponseHeader(String),
48 Message(String),
49 ClientIp,
50 ClientPort,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Segment {
56 Literal(String),
57 Ref(TemplateRef),
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Template {
67 segments: Vec<Segment>,
68 has_refs: bool,
69}
70
71impl Template {
72 pub fn parse(s: &str) -> (Template, Vec<String>) {
77 Self::parse_with_env(s, &|name| std::env::var(name).ok())
78 }
79
80 pub fn parse_with_env(
83 s: &str,
84 env: &dyn Fn(&str) -> Option<String>,
85 ) -> (Template, Vec<String>) {
86 let mut segments: Vec<Segment> = Vec::new();
87 let mut warnings: Vec<String> = Vec::new();
88 let mut literal = String::new();
89 let mut rest = s;
90
91 loop {
92 let Some(idx) = rest.find("{{") else {
93 literal.push_str(rest);
94 break;
95 };
96 literal.push_str(&rest[..idx]);
97 let after_open = &rest[idx + 2..];
98 let Some(close_idx) = after_open.find("}}") else {
99 literal.push_str(&rest[idx..]);
101 break;
102 };
103 let inner = &after_open[..close_idx];
104 let trimmed = inner.trim();
105 let full_match = &rest[idx..idx + 2 + close_idx + 2];
106
107 match classify(trimmed, full_match, env) {
108 Classified::Ref(r) => {
109 if !literal.is_empty() {
110 segments.push(Segment::Literal(std::mem::take(&mut literal)));
111 }
112 segments.push(Segment::Ref(r));
113 }
114 Classified::EnvLiteral(value) => literal.push_str(&value),
115 Classified::PassThrough => literal.push_str(full_match),
116 Classified::Warn(message) => {
117 literal.push_str(full_match);
118 warnings.push(message);
119 }
120 }
121
122 rest = &after_open[close_idx + 2..];
123 }
124
125 if !literal.is_empty() {
126 segments.push(Segment::Literal(literal));
127 }
128 let has_refs = segments.iter().any(|s| matches!(s, Segment::Ref(_)));
129 (Template { segments, has_refs }, warnings)
130 }
131
132 pub fn render(&self, ctx: &Context) -> Cow<'_, str> {
136 if !self.has_refs {
137 return Cow::Borrowed(self.literal_str());
138 }
139 let mut out = String::new();
140 for seg in &self.segments {
141 match seg {
142 Segment::Literal(s) => out.push_str(s),
143 Segment::Ref(r) => render_ref(r, ctx, &mut out),
144 }
145 }
146 Cow::Owned(out)
147 }
148
149 pub fn render_with_legacy(&self, ctx: &Context) -> String {
164 let mut out = String::new();
165 for seg in &self.segments {
166 match seg {
167 Segment::Literal(s) => out.push_str(&crate::vars::interpolate(ctx, s)),
168 Segment::Ref(r) => render_ref(r, ctx, &mut out),
169 }
170 }
171 out
172 }
173
174 fn literal_str(&self) -> &str {
177 match self.segments.first() {
178 Some(Segment::Literal(s)) => s.as_str(),
179 _ => "",
180 }
181 }
182
183 pub fn references_response_body(&self) -> bool {
199 self.segments.iter().any(|seg| match seg {
200 Segment::Ref(TemplateRef::ResponseBody) => true,
201 Segment::Ref(_) => false,
202 Segment::Literal(text) => text.contains("$resp_body"),
203 })
204 }
205
206 pub fn is_literal(&self) -> bool {
207 !self.has_refs
208 }
209
210 pub fn source_warnings_only(s: &str) -> Vec<String> {
214 Self::parse(s).1
215 }
216}
217
218enum Classified {
220 Ref(TemplateRef),
222 EnvLiteral(String),
225 PassThrough,
227 Warn(String),
230}
231
232fn unknown_ref_warning(full_match: &str) -> String {
233 format!("unknown template reference '{full_match}' (passed through literally)")
234}
235
236fn classify(trimmed: &str, full_match: &str, env: &dyn Fn(&str) -> Option<String>) -> Classified {
237 let (first, remainder) = match trimmed.split_once('.') {
238 Some((first, remainder)) => (first, remainder),
239 None => (trimmed, ""),
240 };
241
242 match first {
243 "request" => classify_request(remainder, full_match),
244 "response" => classify_response(remainder, full_match),
245 "message" => classify_message(remainder, full_match),
246 "client" => classify_client(remainder, full_match),
247 "env" => classify_env(remainder, full_match, env),
248 _ => Classified::PassThrough,
249 }
250}
251
252fn classify_request(remainder: &str, full_match: &str) -> Classified {
253 match remainder {
254 "method" => Classified::Ref(TemplateRef::RequestMethod),
255 "path" => Classified::Ref(TemplateRef::RequestPath),
256 "host" => Classified::Ref(TemplateRef::RequestHost),
257 "scheme" => Classified::Ref(TemplateRef::RequestScheme),
258 "body" => Classified::Ref(TemplateRef::RequestBody),
259 _ => {
260 if let Some(name) = remainder.strip_prefix("headers.") {
261 named_ref(name, full_match, |n| {
262 TemplateRef::RequestHeader(n.to_string())
263 })
264 } else if let Some(name) = remainder.strip_prefix("query.") {
265 named_ref(name, full_match, |n| {
266 TemplateRef::RequestQuery(n.to_string())
267 })
268 } else if let Some(name) = remainder.strip_prefix("cookies.") {
269 named_ref(name, full_match, |n| {
270 TemplateRef::RequestCookie(n.to_string())
271 })
272 } else {
273 Classified::Warn(unknown_ref_warning(full_match))
274 }
275 }
276 }
277}
278
279fn classify_response(remainder: &str, full_match: &str) -> Classified {
280 match remainder {
281 "status" => Classified::Ref(TemplateRef::ResponseStatus),
282 "body" => Classified::Ref(TemplateRef::ResponseBody),
283 _ => {
284 if let Some(name) = remainder.strip_prefix("headers.") {
285 named_ref(name, full_match, |n| {
286 TemplateRef::ResponseHeader(n.to_string())
287 })
288 } else {
289 Classified::Warn(unknown_ref_warning(full_match))
290 }
291 }
292 }
293}
294
295fn classify_message(remainder: &str, full_match: &str) -> Classified {
296 if remainder.is_empty() {
297 Classified::Warn(unknown_ref_warning(full_match))
298 } else {
299 Classified::Ref(TemplateRef::Message(remainder.to_string()))
300 }
301}
302
303fn classify_client(remainder: &str, full_match: &str) -> Classified {
304 match remainder {
305 "ip" => Classified::Ref(TemplateRef::ClientIp),
306 "port" => Classified::Ref(TemplateRef::ClientPort),
307 _ => Classified::Warn(unknown_ref_warning(full_match)),
308 }
309}
310
311fn classify_env(
312 remainder: &str,
313 full_match: &str,
314 env: &dyn Fn(&str) -> Option<String>,
315) -> Classified {
316 if remainder.is_empty() {
317 return Classified::Warn(unknown_ref_warning(full_match));
318 }
319 match env(remainder) {
320 Some(value) => Classified::EnvLiteral(value),
321 None => Classified::Warn(format!(
322 "unset environment variable '{remainder}' referenced in template '{full_match}' (passed through literally)"
323 )),
324 }
325}
326
327fn named_ref(name: &str, full_match: &str, make: impl FnOnce(&str) -> TemplateRef) -> Classified {
330 if name.is_empty() {
331 Classified::Warn(unknown_ref_warning(full_match))
332 } else {
333 Classified::Ref(make(name))
334 }
335}
336
337fn render_ref(r: &TemplateRef, ctx: &Context, out: &mut String) {
338 match r {
339 TemplateRef::RequestMethod => out.push_str(&ctx.request.method),
340 TemplateRef::RequestPath => out.push_str(&ctx.request.path),
341 TemplateRef::RequestHost => out.push_str(&ctx.request.host),
342 TemplateRef::RequestScheme => out.push_str(&ctx.request.scheme),
343 TemplateRef::RequestBody => {
344 out.push_str(&String::from_utf8_lossy(&ctx.request.body));
345 }
346 TemplateRef::RequestHeader(name) => {
347 let key = name.to_lowercase();
348 if let Some(v) = ctx.request.headers.get(&key).and_then(|v| v.first()) {
349 out.push_str(v);
350 }
351 }
352 TemplateRef::RequestQuery(name) => {
353 if let Some(v) = ctx.request.query_params.get(name).and_then(|v| v.first()) {
354 out.push_str(v);
355 }
356 }
357 TemplateRef::RequestCookie(name) => {
358 if let Some(v) = cookie_value(ctx, name) {
359 out.push_str(&v);
360 }
361 }
362 TemplateRef::ResponseStatus => out.push_str(&ctx.response.status_code.to_string()),
363 TemplateRef::ResponseBody => {
364 out.push_str(&String::from_utf8_lossy(&ctx.response.body));
365 }
366 TemplateRef::ResponseHeader(name) => {
367 let key = name.to_lowercase();
368 if let Some(v) = ctx.response.headers.get(&key).and_then(|v| v.first()) {
369 out.push_str(v);
370 }
371 }
372 TemplateRef::Message(key) => {
373 if let Some(v) = message_str(ctx, key) {
374 out.push_str(&v);
375 }
376 }
377 TemplateRef::ClientIp => out.push_str(split_remote_addr(&ctx.request.remote_addr).0),
378 TemplateRef::ClientPort => {
379 if let Some(port) = split_remote_addr(&ctx.request.remote_addr).1 {
380 out.push_str(port);
381 }
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
390 use bytes::Bytes;
391 use std::collections::HashMap;
392
393 fn test_ctx() -> Context {
394 let mut headers = HashMap::new();
395 headers.insert("x-user-id".to_string(), vec!["u-42".to_string()]);
396 headers.insert(
397 "cookie".to_string(),
398 vec!["session=abc123; theme=dark".to_string()],
399 );
400 let mut query = HashMap::new();
401 query.insert(
402 "name".to_string(),
403 vec!["jack".to_string(), "jill".to_string()],
404 );
405 let mut message = HashMap::new();
406 message.insert("consumer.key_id".to_string(), serde_json::json!("key-123"));
407 message.insert("retries".to_string(), serde_json::json!(3));
408
409 Context {
410 request: GatewayRequest {
411 method: "GET".to_string(),
412 path: "/api/users".to_string(),
413 host: "example.com".to_string(),
414 scheme: "https".to_string(),
415 headers,
416 query_params: query,
417 body: Bytes::from_static(b"req-body"),
418 remote_addr: "10.1.2.3:44321".to_string(),
419 protocol: Protocol::Http1,
420 },
421 response: GatewayResponse {
422 status_code: 200,
423 headers: {
424 let mut h = HashMap::new();
425 h.insert("x-cache".to_string(), vec!["HIT".to_string()]);
426 h
427 },
428 body: Bytes::from_static(b"resp-body"),
429 stream: None,
430 },
431 message,
432 errors: Vec::new(),
433 }
434 }
435
436 fn no_env(_: &str) -> Option<String> {
437 None
438 }
439
440 #[test]
443 fn test_literal_only_fast_path() {
444 let (tpl, warnings) = Template::parse("just plain text, no braces here");
445 assert!(warnings.is_empty());
446 assert!(tpl.is_literal());
447 let ctx = test_ctx();
448 match tpl.render(&ctx) {
449 Cow::Borrowed(s) => assert_eq!(s, "just plain text, no braces here"),
450 Cow::Owned(_) => panic!("literal-only template must render Cow::Borrowed"),
451 }
452 }
453
454 #[test]
455 fn test_parse_known_refs() {
456 let (tpl, warnings) = Template::parse("{{request.method}} {{ response.status }}");
457 assert!(warnings.is_empty(), "warnings: {warnings:?}");
458 assert!(!tpl.is_literal());
459 let refs: Vec<&TemplateRef> = tpl
460 .segments
461 .iter()
462 .filter_map(|s| match s {
463 Segment::Ref(r) => Some(r),
464 _ => None,
465 })
466 .collect();
467 assert_eq!(refs.len(), 2);
468 assert_eq!(refs[0], &TemplateRef::RequestMethod);
469 assert_eq!(refs[1], &TemplateRef::ResponseStatus);
470 }
471
472 #[test]
473 fn test_unknown_namespace_passes_silently() {
474 for input in ["{{ $1 }}", "{{mustache}}", "{{body.x}}"] {
475 let (tpl, warnings) = Template::parse(input);
476 assert!(warnings.is_empty(), "input {input}: warnings {warnings:?}");
477 assert!(tpl.is_literal(), "input {input}: should be literal");
478 let ctx = test_ctx();
479 assert_eq!(
480 tpl.render(&ctx),
481 input,
482 "input {input}: should pass through verbatim"
483 );
484 }
485 }
486
487 #[test]
488 fn test_known_namespace_bad_leaf_warns() {
489 for input in ["{{request.headres.x}}", "{{client.mac}}"] {
490 let (tpl, warnings) = Template::parse(input);
491 assert_eq!(warnings.len(), 1, "input {input}: warnings {warnings:?}");
492 assert!(tpl.is_literal());
493 let ctx = test_ctx();
494 assert_eq!(tpl.render(&ctx), input);
495 }
496 }
497
498 #[test]
499 fn test_env_substituted_at_parse() {
500 let env = |name: &str| -> Option<String> {
501 if name == "REGION" {
502 Some("eu".to_string())
503 } else {
504 None
505 }
506 };
507 let (tpl, warnings) = Template::parse_with_env("{{env.REGION}}", &env);
508 assert!(warnings.is_empty());
509 assert!(tpl.is_literal());
510 let ctx = test_ctx();
511 assert_eq!(tpl.render(&ctx), "eu");
512 }
513
514 #[test]
515 fn test_env_unset_passes_and_warns() {
516 let (tpl, warnings) = Template::parse_with_env("{{env.NOPE}}", &no_env);
517 assert_eq!(warnings.len(), 1);
518 assert!(tpl.is_literal());
519 let ctx = test_ctx();
520 assert_eq!(tpl.render(&ctx), "{{env.NOPE}}");
521 }
522
523 #[test]
524 fn test_unclosed_braces_literal() {
525 let (tpl, warnings) = Template::parse("{{request.method");
526 assert!(warnings.is_empty());
527 assert!(tpl.is_literal());
528 let ctx = test_ctx();
529 assert_eq!(tpl.render(&ctx), "{{request.method");
530 }
531
532 #[test]
533 fn test_message_key_with_dots() {
534 let (tpl, warnings) = Template::parse("{{message.consumer.key_id}}");
535 assert!(warnings.is_empty());
536 assert_eq!(
537 tpl.segments,
538 vec![Segment::Ref(TemplateRef::Message(
539 "consumer.key_id".to_string()
540 ))]
541 );
542 }
543
544 #[test]
547 fn test_render_request_basics() {
548 let (tpl, _) = Template::parse(
549 "{{request.method}} {{request.path}} {{request.host}} {{request.scheme}}",
550 );
551 let ctx = test_ctx();
552 assert_eq!(tpl.render(&ctx), "GET /api/users example.com https");
553 }
554
555 #[test]
556 fn test_render_headers_case_insensitive_dashes() {
557 let (tpl, warnings) = Template::parse("{{request.headers.X-User-ID}}");
558 assert!(warnings.is_empty());
559 let ctx = test_ctx();
560 assert_eq!(tpl.render(&ctx), "u-42");
561 }
562
563 #[test]
564 fn test_render_response_header_and_status() {
565 let (tpl, _) = Template::parse("{{response.status}} {{response.headers.x-cache}}");
566 let ctx = test_ctx();
567 assert_eq!(tpl.render(&ctx), "200 HIT");
568 }
569
570 #[test]
571 fn test_render_query_first_value() {
572 let (tpl, _) = Template::parse("{{request.query.name}}");
573 let ctx = test_ctx();
574 assert_eq!(tpl.render(&ctx), "jack");
575 }
576
577 #[test]
578 fn test_render_cookie() {
579 let (tpl, _) = Template::parse("{{request.cookies.theme}}");
580 let ctx = test_ctx();
581 assert_eq!(tpl.render(&ctx), "dark");
582 }
583
584 #[test]
585 fn test_render_message_stringified() {
586 let (tpl, _) = Template::parse("{{message.consumer.key_id}} {{message.retries}}");
587 let ctx = test_ctx();
588 assert_eq!(tpl.render(&ctx), "key-123 3");
589 }
590
591 #[test]
592 fn test_render_client_ip_port() {
593 let (tpl, _) = Template::parse("{{client.ip}}:{{client.port}}");
594 let ctx = test_ctx();
595 assert_eq!(tpl.render(&ctx), "10.1.2.3:44321");
596 }
597
598 #[test]
599 fn test_render_bodies_lossy() {
600 let (tpl, _) = Template::parse("{{request.body}}|{{response.body}}");
601 let ctx = test_ctx();
602 assert_eq!(tpl.render(&ctx), "req-body|resp-body");
603 }
604
605 #[test]
606 fn test_absent_subject_renders_empty() {
607 let (tpl, _) = Template::parse("[{{request.headers.missing}}]");
608 let ctx = test_ctx();
609 assert_eq!(tpl.render(&ctx), "[]");
610 }
611
612 #[test]
613 fn test_dollar_untouched_in_render() {
614 let (tpl, _) = Template::parse("pa$sword4 {{request.method}}");
615 let ctx = test_ctx();
616 assert_eq!(tpl.render(&ctx), "pa$sword4 GET");
617 }
618
619 #[test]
622 fn test_render_with_legacy_applies_dollar_after() {
623 let (tpl, _) = Template::parse("{{request.method}} $uri");
624 let ctx = test_ctx();
625 assert_eq!(tpl.render_with_legacy(&ctx), "GET /api/users");
626 }
627
628 #[test]
629 fn test_render_plain_ignores_dollar() {
630 let (tpl, _) = Template::parse("$uri");
631 let ctx = test_ctx();
632 assert_eq!(tpl.render(&ctx), "$uri");
633 }
634
635 #[test]
636 fn test_legacy_does_not_interpolate_ref_output() {
637 let mut ctx = test_ctx();
642 ctx.request
643 .headers
644 .insert("x-password".to_string(), vec!["pa$sword4".to_string()]);
645 let (tpl, _) = Template::parse("{{request.headers.x-password}} $uri");
646 assert_eq!(tpl.render_with_legacy(&ctx), "pa$sword4 /api/users");
647 }
648
649 #[test]
650 fn test_legacy_ref_output_cannot_read_other_vars() {
651 let mut ctx = test_ctx();
655 ctx.request.headers.insert(
656 "authorization".to_string(),
657 vec!["Bearer secret-token-xyz".to_string()],
658 );
659 ctx.request.headers.insert(
660 "x-tenant".to_string(),
661 vec!["$http_authorization".to_string()],
662 );
663 let (tpl, _) = Template::parse("tenant={{request.headers.x-tenant}}");
664 let rendered = tpl.render_with_legacy(&ctx);
665 assert_eq!(rendered, "tenant=$http_authorization");
666 assert!(
667 !rendered.contains("secret-token-xyz"),
668 "ref output must not be re-interpolated as a $var read primitive: {rendered}"
669 );
670 }
671
672 #[test]
676 fn test_template_reports_a_response_body_reference() {
677 let (tpl, _) = Template::parse("status={{response.body}}");
678 assert!(tpl.references_response_body());
679 }
680
681 #[test]
685 fn test_template_reports_a_legacy_resp_body_reference() {
686 let (tpl, _) = Template::parse("body=$resp_body");
687 assert!(tpl.references_response_body());
688 }
689
690 #[test]
693 fn test_template_without_a_body_reference_is_stream_safe() {
694 let (tpl, _) = Template::parse("{{request.path}} {{response.status}} $http_host");
695 assert!(!tpl.references_response_body());
696 let (literal, _) = Template::parse("a plain string");
697 assert!(!literal.references_response_body());
698 }
699}