1use regex::Regex;
61use serde::Deserialize;
62use std::collections::HashMap;
63use std::fmt;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum PlaceholderKind {
68 Any,
70 Int,
72 Slug,
74 Multi,
78}
79
80impl PlaceholderKind {
81 fn regex_fragment(self) -> &'static str {
82 match self {
83 PlaceholderKind::Any => "[^.]+",
84 PlaceholderKind::Int => r"\d+",
85 PlaceholderKind::Slug => "[a-z0-9-]+",
86 PlaceholderKind::Multi => r"[^.]+(?:\.[^.]+)*",
87 }
88 }
89}
90
91fn special_regex_fragment(name: &str) -> Option<&'static str> {
101 match name {
102 "domain" => Some(r"[a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)+"),
103 _ => None,
104 }
105}
106
107#[derive(Debug, Clone)]
109pub struct Placeholder {
110 pub name: String,
111 pub kind: PlaceholderKind,
112}
113
114#[derive(Debug, Clone, Deserialize)]
116pub struct RouteConfig {
117 pub name: String,
118 #[serde(rename = "match")]
121 pub r#match: String,
122 pub target: String,
124 #[serde(default)]
127 pub headers: Option<HashMap<String, String>>,
128}
129
130#[derive(Debug, Clone, Deserialize)]
132pub struct RoutesFile {
133 #[serde(default = "default_version")]
134 pub version: u32,
135 pub routes: Vec<RouteConfig>,
136}
137
138fn default_version() -> u32 {
139 1
140}
141
142pub fn parse_yaml(src: &str) -> Result<RoutesFile, serde_yaml::Error> {
144 serde_yaml::from_str(src)
145}
146
147#[derive(Debug, Clone)]
149pub struct CompiledRoute {
150 pub name: String,
151 pub pattern: Regex,
152 pub placeholders: Vec<Placeholder>,
153 pub target_template: String,
154 pub header_templates: HashMap<String, String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct RouteHit {
160 pub route_name: String,
161 pub target: String,
163 pub host_header: Option<String>,
165 pub other_headers: HashMap<String, String>,
167}
168
169#[derive(Debug, Clone)]
171pub enum CompileError {
172 InvalidPlaceholder { route: String, placeholder: String, reason: String },
174 UnknownKind { route: String, name: String, kind: String },
176 DuplicatePlaceholder { route: String, name: String },
178 InvalidRegex { route: String, source: String },
181 UndeclaredPlaceholder { route: String, name: String, location: String },
184 UnbalancedBraces { route: String, location: String },
186}
187
188impl fmt::Display for CompileError {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 match self {
191 CompileError::InvalidPlaceholder { route, placeholder, reason } => {
192 write!(f, "route '{}': invalid placeholder '{{{}}}': {}", route, placeholder, reason)
193 }
194 CompileError::UnknownKind { route, name, kind } => {
195 write!(f, "route '{}': unknown placeholder kind ':{}' for '{{{}}}' (expected int|slug|multi or none)", route, kind, name)
196 }
197 CompileError::DuplicatePlaceholder { route, name } => {
198 write!(f, "route '{}': placeholder '{{{}}}' declared twice in match pattern", route, name)
199 }
200 CompileError::InvalidRegex { route, source } => {
201 write!(f, "route '{}': internal regex compile error: {}", route, source)
202 }
203 CompileError::UndeclaredPlaceholder { route, name, location } => {
204 write!(f, "route '{}': placeholder '{{{}}}' used in {} but never declared in match pattern", route, name, location)
205 }
206 CompileError::UnbalancedBraces { route, location } => {
207 write!(f, "route '{}': unbalanced braces in {}", route, location)
208 }
209 }
210 }
211}
212
213impl std::error::Error for CompileError {}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
221enum Token {
222 Literal(String),
223 Placeholder { name: String, kind: Option<String> },
224}
225
226fn tokenize(s: &str, route: &str, location: &str) -> Result<Vec<Token>, CompileError> {
229 let mut out = Vec::new();
230 let mut buf = String::new();
231 let mut chars = s.chars().peekable();
232
233 while let Some(c) = chars.next() {
234 if c == '{' {
235 if !buf.is_empty() {
237 out.push(Token::Literal(std::mem::take(&mut buf)));
238 }
239 let mut spec = String::new();
241 let mut closed = false;
242 while let Some(&nc) = chars.peek() {
243 chars.next();
244 if nc == '}' {
245 closed = true;
246 break;
247 }
248 spec.push(nc);
249 }
250 if !closed {
251 return Err(CompileError::UnbalancedBraces {
252 route: route.to_string(),
253 location: location.to_string(),
254 });
255 }
256 let (name, kind) = match spec.split_once(':') {
258 Some((n, k)) => (n.to_string(), Some(k.to_string())),
259 None => (spec.clone(), None),
260 };
261 out.push(Token::Placeholder { name, kind });
262 } else if c == '}' {
263 return Err(CompileError::UnbalancedBraces {
264 route: route.to_string(),
265 location: location.to_string(),
266 });
267 } else {
268 buf.push(c);
269 }
270 }
271 if !buf.is_empty() {
272 out.push(Token::Literal(buf));
273 }
274 Ok(out)
275}
276
277fn parse_kind(route: &str, name: &str, kind: Option<&str>) -> Result<PlaceholderKind, CompileError> {
278 match kind {
279 None | Some("") => Ok(PlaceholderKind::Any),
280 Some("int") => Ok(PlaceholderKind::Int),
281 Some("slug") => Ok(PlaceholderKind::Slug),
282 Some("multi") => Ok(PlaceholderKind::Multi),
283 Some(other) => Err(CompileError::UnknownKind {
284 route: route.to_string(),
285 name: name.to_string(),
286 kind: other.to_string(),
287 }),
288 }
289}
290
291fn validate_name(route: &str, raw_spec: &str, name: &str) -> Result<(), CompileError> {
292 if name.is_empty() {
293 return Err(CompileError::InvalidPlaceholder {
294 route: route.to_string(),
295 placeholder: raw_spec.to_string(),
296 reason: "empty placeholder name".to_string(),
297 });
298 }
299 let first = name.chars().next().unwrap();
300 if !(first.is_ascii_alphabetic() || first == '_') {
301 return Err(CompileError::InvalidPlaceholder {
302 route: route.to_string(),
303 placeholder: raw_spec.to_string(),
304 reason: "name must start with a letter or '_'".to_string(),
305 });
306 }
307 for c in name.chars() {
308 if !(c.is_ascii_alphanumeric() || c == '_') {
309 return Err(CompileError::InvalidPlaceholder {
310 route: route.to_string(),
311 placeholder: raw_spec.to_string(),
312 reason: format!("name contains invalid character '{}'", c),
313 });
314 }
315 }
316 Ok(())
317}
318
319pub fn compile(routes: Vec<RouteConfig>) -> Result<Vec<CompiledRoute>, CompileError> {
327 let mut out = Vec::with_capacity(routes.len());
328 for r in routes {
329 out.push(compile_one(r)?);
330 }
331 Ok(out)
332}
333
334fn compile_one(cfg: RouteConfig) -> Result<CompiledRoute, CompileError> {
335 let route_name = cfg.name.clone();
336 let tokens = tokenize(&cfg.r#match, &route_name, "match pattern")?;
337
338 let mut declared: Vec<Placeholder> = Vec::new();
339 let mut regex_src = String::from("^");
340 for tok in &tokens {
341 match tok {
342 Token::Literal(lit) => {
343 regex_src.push_str(®ex::escape(lit));
344 }
345 Token::Placeholder { name, kind } => {
346 let raw_spec = match kind {
347 Some(k) => format!("{}:{}", name, k),
348 None => name.clone(),
349 };
350 validate_name(&route_name, &raw_spec, name)?;
351 let parsed_kind = parse_kind(&route_name, name, kind.as_deref())?;
352 if declared.iter().any(|p| p.name == *name) {
353 return Err(CompileError::DuplicatePlaceholder {
354 route: route_name,
355 name: name.clone(),
356 });
357 }
358 declared.push(Placeholder { name: name.clone(), kind: parsed_kind });
359 regex_src.push('(');
360 regex_src.push_str("?P<");
361 regex_src.push_str(name);
362 regex_src.push('>');
363 if kind.is_none() {
369 if let Some(frag) = special_regex_fragment(name) {
370 regex_src.push_str(frag);
371 } else {
372 regex_src.push_str(parsed_kind.regex_fragment());
373 }
374 } else {
375 regex_src.push_str(parsed_kind.regex_fragment());
376 }
377 regex_src.push(')');
378 }
379 }
380 }
381 regex_src.push('$');
382
383 let pattern = Regex::new(®ex_src).map_err(|e| CompileError::InvalidRegex {
384 route: route_name.clone(),
385 source: e.to_string(),
386 })?;
387
388 let target_tokens = tokenize(&cfg.target, &route_name, "target template")?;
390 for tok in &target_tokens {
391 if let Token::Placeholder { name, .. } = tok {
392 validate_name(&route_name, name, name)?;
393 if !declared.iter().any(|p| p.name == *name) {
394 return Err(CompileError::UndeclaredPlaceholder {
395 route: route_name,
396 name: name.clone(),
397 location: "target template".to_string(),
398 });
399 }
400 }
401 }
402
403 let mut header_templates: HashMap<String, String> = HashMap::new();
404 if let Some(headers) = cfg.headers {
405 for (k, v) in headers {
406 let header_tokens = tokenize(&v, &route_name, &format!("header '{}'", k))?;
407 for tok in &header_tokens {
408 if let Token::Placeholder { name, .. } = tok {
409 validate_name(&route_name, name, name)?;
410 if !declared.iter().any(|p| p.name == *name) {
411 return Err(CompileError::UndeclaredPlaceholder {
412 route: route_name.clone(),
413 name: name.clone(),
414 location: format!("header '{}'", k),
415 });
416 }
417 }
418 }
419 header_templates.insert(k, v);
420 }
421 }
422
423 Ok(CompiledRoute {
424 name: route_name,
425 pattern,
426 placeholders: declared,
427 target_template: cfg.target,
428 header_templates,
429 })
430}
431
432fn strip_port(host: &str) -> &str {
439 match host.rfind(':') {
440 Some(i) => &host[..i],
441 None => host,
442 }
443}
444
445fn strip_trailing_slash(host: &str) -> &str {
447 host.strip_suffix('/').unwrap_or(host)
448}
449
450fn normalize(host: &str) -> String {
451 strip_trailing_slash(strip_port(host)).to_ascii_lowercase()
453}
454
455fn expand(template: &str, captures: &HashMap<String, String>) -> String {
457 let mut out = String::with_capacity(template.len());
461 let mut chars = template.chars().peekable();
462 while let Some(c) = chars.next() {
463 if c == '{' {
464 let mut spec = String::new();
465 while let Some(&nc) = chars.peek() {
466 chars.next();
467 if nc == '}' {
468 break;
469 }
470 spec.push(nc);
471 }
472 let name = match spec.split_once(':') {
474 Some((n, _)) => n.to_string(),
475 None => spec,
476 };
477 if let Some(v) = captures.get(&name) {
478 out.push_str(v);
479 }
480 } else {
483 out.push(c);
484 }
485 }
486 out
487}
488
489pub fn match_host(routes: &[CompiledRoute], host: &str) -> Option<RouteHit> {
492 match_host_with_domain(routes, host, None)
493}
494
495pub fn match_host_with_domain(
504 routes: &[CompiledRoute],
505 host: &str,
506 default_domain: Option<&str>,
507) -> Option<RouteHit> {
508 let host = normalize(host);
509
510 if let Some(domain) = default_domain {
511 if !domain.is_empty() {
512 let domain_lc = domain.to_ascii_lowercase();
513 if host != domain_lc && !host.ends_with(&format!(".{}", domain_lc)) {
514 return None;
515 }
516 }
517 }
518
519 for route in routes {
520 if let Some(caps) = route.pattern.captures(&host) {
521 let mut values: HashMap<String, String> = HashMap::new();
522 for p in &route.placeholders {
523 if let Some(m) = caps.name(&p.name) {
524 values.insert(p.name.clone(), m.as_str().to_string());
525 }
526 }
527
528 let target = expand(&route.target_template, &values);
529
530 let mut host_header: Option<String> = None;
531 let mut other_headers: HashMap<String, String> = HashMap::new();
532 for (k, tmpl) in &route.header_templates {
533 let v = expand(tmpl, &values);
534 if k.eq_ignore_ascii_case("host") {
535 host_header = Some(v);
536 } else {
537 other_headers.insert(k.clone(), v);
538 }
539 }
540
541 return Some(RouteHit {
542 route_name: route.name.clone(),
543 target,
544 host_header,
545 other_headers,
546 });
547 }
548 }
549 None
550}
551
552#[cfg(test)]
557mod tests {
558 use super::*;
559
560 fn default_routes() -> Vec<CompiledRoute> {
561 let configs = vec![
562 RouteConfig {
563 name: "port-as-host".into(),
564 r#match: "{port:int}.{domain}".into(),
565 target: "127.0.0.1:{port}".into(),
566 headers: None,
567 },
568 RouteConfig {
569 name: "host-double-dash-port".into(),
570 r#match: "{host}--{port:int}.{domain}".into(),
571 target: "{host}:{port}".into(),
572 headers: Some({
573 let mut h = HashMap::new();
574 h.insert("Host".into(), "{host}".into());
575 h
576 }),
577 },
578 RouteConfig {
579 name: "subdomain-hoisting".into(),
580 r#match: "{prefix}.{host}.{domain}".into(),
581 target: "{host}:80".into(),
582 headers: Some({
583 let mut h = HashMap::new();
584 h.insert("Host".into(), "{prefix}".into());
585 h
586 }),
587 },
588 RouteConfig {
589 name: "direct-forward".into(),
590 r#match: "{host}.{domain}".into(),
591 target: "{host}:80".into(),
592 headers: Some({
593 let mut h = HashMap::new();
594 h.insert("Host".into(), "{host}".into());
595 h
596 }),
597 },
598 ];
599 compile(configs).expect("compile default routes")
600 }
601
602 fn m(routes: &[CompiledRoute], host: &str) -> Option<RouteHit> {
607 match_host_with_domain(routes, host, Some("fbi.com"))
608 }
609
610 #[test]
611 fn empty_routes_no_match() {
612 let hit = match_host(&[], "anything.fbi.com");
613 assert!(hit.is_none());
614 }
615
616 #[test]
617 fn port_as_host_matches() {
618 let routes = default_routes();
619 let hit = m(&routes, "3000.fbi.com").expect("should match");
620 assert_eq!(hit.route_name, "port-as-host");
621 assert_eq!(hit.target, "127.0.0.1:3000");
622 assert_eq!(hit.host_header, None);
623 }
624
625 #[test]
626 fn host_double_dash_port_matches() {
627 let routes = default_routes();
628 let hit = m(&routes, "api--3001.fbi.com").expect("should match");
629 assert_eq!(hit.route_name, "host-double-dash-port");
630 assert_eq!(hit.target, "api:3001");
631 assert_eq!(hit.host_header.as_deref(), Some("api"));
632 }
633
634 #[test]
635 fn subdomain_hoisting_matches() {
636 let routes = default_routes();
637 let hit = m(&routes, "admin.app.fbi.com").expect("should match");
638 assert_eq!(hit.route_name, "subdomain-hoisting");
639 assert_eq!(hit.target, "app:80");
640 assert_eq!(hit.host_header.as_deref(), Some("admin"));
641 }
642
643 #[test]
644 fn direct_forward_matches() {
645 let routes = default_routes();
646 let hit = m(&routes, "myserver.fbi.com").expect("should match");
647 assert_eq!(hit.route_name, "direct-forward");
648 assert_eq!(hit.target, "myserver:80");
649 assert_eq!(hit.host_header.as_deref(), Some("myserver"));
650 }
651
652 #[test]
653 fn port_in_host_is_stripped_before_match() {
654 let routes = default_routes();
655 let hit = m(&routes, "myserver.fbi.com:8080").expect("should match");
656 assert_eq!(hit.route_name, "direct-forward");
657 assert_eq!(hit.target, "myserver:80");
658 }
659
660 #[test]
661 fn trailing_slash_stripped() {
662 let routes = default_routes();
663 let hit = m(&routes, "3000.fbi.com/").expect("should match");
664 assert_eq!(hit.route_name, "port-as-host");
665 }
666
667 #[test]
668 fn host_header_is_case_insensitive() {
669 let routes = default_routes();
670 let hit = m(&routes, "API--3001.FBI.COM").expect("should match");
671 assert_eq!(hit.route_name, "host-double-dash-port");
672 assert_eq!(hit.target, "api:3001");
673 }
674
675 #[test]
676 fn multi_dot_subdomain_assigns_domain_greedily() {
677 let routes = default_routes();
689 let hit = match_host(&routes, "a.b.c.fbi.com").expect("should match");
690 assert_eq!(hit.route_name, "subdomain-hoisting");
691 assert_eq!(hit.target, "b:80");
693 assert_eq!(hit.host_header.as_deref(), Some("a"));
695 }
696
697 #[test]
698 fn multi_dot_subdomain_with_domain_filter_is_unambiguous() {
699 let routes = default_routes();
708 let hit = match_host_with_domain(&routes, "admin.app.fbi.com", Some("fbi.com"))
710 .expect("should match");
711 assert_eq!(hit.route_name, "subdomain-hoisting");
712 assert_eq!(hit.target, "app:80");
713 assert_eq!(hit.host_header.as_deref(), Some("admin"));
714 }
715
716 #[test]
717 fn first_match_wins() {
718 let routes = compile(vec![
719 RouteConfig {
720 name: "first".into(),
721 r#match: "{x}.{y}".into(),
722 target: "first-target".into(),
723 headers: None,
724 },
725 RouteConfig {
726 name: "second".into(),
727 r#match: "{x}.{y}".into(),
728 target: "second-target".into(),
729 headers: None,
730 },
731 ])
732 .unwrap();
733 let hit = match_host(&routes, "a.b").expect("should match");
734 assert_eq!(hit.route_name, "first");
735 assert_eq!(hit.target, "first-target");
736 }
737
738 #[test]
739 fn unknown_placeholder_kind_errors() {
740 let err = compile(vec![RouteConfig {
741 name: "bad".into(),
742 r#match: "{port:zzz}.com".into(),
743 target: "x".into(),
744 headers: None,
745 }])
746 .unwrap_err();
747 match err {
748 CompileError::UnknownKind { kind, .. } => assert_eq!(kind, "zzz"),
749 e => panic!("expected UnknownKind, got {:?}", e),
750 }
751 }
752
753 #[test]
754 fn unbalanced_braces_in_pattern_errors() {
755 let err = compile(vec![RouteConfig {
756 name: "bad".into(),
757 r#match: "{port".into(),
758 target: "x".into(),
759 headers: None,
760 }])
761 .unwrap_err();
762 match err {
763 CompileError::UnbalancedBraces { location, .. } => {
764 assert!(location.contains("match"))
765 }
766 e => panic!("expected UnbalancedBraces, got {:?}", e),
767 }
768 }
769
770 #[test]
771 fn duplicate_placeholder_errors() {
772 let err = compile(vec![RouteConfig {
773 name: "bad".into(),
774 r#match: "{x}.{x}".into(),
775 target: "y".into(),
776 headers: None,
777 }])
778 .unwrap_err();
779 match err {
780 CompileError::DuplicatePlaceholder { name, .. } => assert_eq!(name, "x"),
781 e => panic!("expected DuplicatePlaceholder, got {:?}", e),
782 }
783 }
784
785 #[test]
786 fn undeclared_placeholder_in_target_errors() {
787 let err = compile(vec![RouteConfig {
788 name: "bad".into(),
789 r#match: "{x}.{y}".into(),
790 target: "{z}".into(),
791 headers: None,
792 }])
793 .unwrap_err();
794 match err {
795 CompileError::UndeclaredPlaceholder { name, location, .. } => {
796 assert_eq!(name, "z");
797 assert!(location.contains("target"));
798 }
799 e => panic!("expected UndeclaredPlaceholder, got {:?}", e),
800 }
801 }
802
803 #[test]
804 fn invalid_placeholder_name_errors() {
805 let err = compile(vec![RouteConfig {
806 name: "bad".into(),
807 r#match: "{1foo}".into(),
808 target: "x".into(),
809 headers: None,
810 }])
811 .unwrap_err();
812 match err {
813 CompileError::InvalidPlaceholder { .. } => {}
814 e => panic!("expected InvalidPlaceholder, got {:?}", e),
815 }
816 }
817
818 #[test]
819 fn int_kind_rejects_non_numeric() {
820 let routes = default_routes();
821 let hit = m(&routes, "abc.fbi.com").expect("should match");
824 assert_eq!(hit.route_name, "direct-forward");
825 assert_eq!(hit.target, "abc:80");
826 }
827
828 #[test]
829 fn match_host_with_domain_filter_accepts_matching() {
830 let routes = default_routes();
831 let hit = match_host_with_domain(&routes, "3000.fbi.com", Some("fbi.com"))
832 .expect("should match");
833 assert_eq!(hit.route_name, "port-as-host");
834 assert_eq!(hit.target, "127.0.0.1:3000");
835 }
836
837 #[test]
838 fn match_host_with_domain_filter_rejects_non_matching() {
839 let routes = default_routes();
840 let hit = match_host_with_domain(&routes, "evil.example.com", Some("fbi.com"));
841 assert!(hit.is_none());
842 }
843
844 #[test]
845 fn match_host_with_multi_dot_domain() {
846 let routes = compile(vec![RouteConfig {
850 name: "direct".into(),
851 r#match: "{host}.{domain}".into(),
852 target: "{host}:80".into(),
853 headers: None,
854 }])
855 .unwrap();
856 let hit =
857 match_host_with_domain(&routes, "myserver.fbi.example.com", Some("fbi.example.com"))
858 .expect("should match");
859 assert_eq!(hit.target, "myserver:80");
860 }
861
862 #[test]
863 fn match_host_with_multi_dot_domain_rejects_wrong_suffix() {
864 let routes = compile(vec![RouteConfig {
865 name: "direct".into(),
866 r#match: "{host}.{domain}".into(),
867 target: "{host}:80".into(),
868 headers: None,
869 }])
870 .unwrap();
871 let hit = match_host_with_domain(&routes, "myserver.other.com", Some("fbi.example.com"));
872 assert!(hit.is_none());
873 }
874
875 #[test]
876 fn multi_kind_captures_multi_dot_segments() {
877 let routes = compile(vec![RouteConfig {
878 name: "dns-passthrough".into(),
879 r#match: "{upstream:multi}.fbi.com".into(),
880 target: "{upstream}:80".into(),
881 headers: None,
882 }])
883 .unwrap();
884
885 let hit = match_host(&routes, "github.com.fbi.com").unwrap();
886 assert_eq!(hit.target, "github.com:80");
887
888 let hit = match_host(&routes, "api.example.org.fbi.com").unwrap();
889 assert_eq!(hit.target, "api.example.org:80");
890
891 let hit = match_host(&routes, "single.fbi.com").unwrap();
893 assert_eq!(hit.target, "single:80");
894 }
895
896 #[test]
897 fn multi_kind_with_host_header_rewrite() {
898 let routes = compile(vec![RouteConfig {
899 name: "dns-with-host".into(),
900 r#match: "{upstream:multi}.fbi.com".into(),
901 target: "{upstream}:443".into(),
902 headers: Some(HashMap::from([("Host".into(), "{upstream}".into())])),
903 }])
904 .unwrap();
905 let hit = match_host(&routes, "api.example.com.fbi.com").unwrap();
906 assert_eq!(hit.target, "api.example.com:443");
907 assert_eq!(hit.host_header.as_deref(), Some("api.example.com"));
908 }
909
910 #[test]
911 fn multi_kind_with_routes_yaml() {
912 let yaml = r#"
913routes:
914 - name: dns-passthrough
915 match: "{upstream:multi}.{domain}"
916 target: "{upstream}:80"
917"#;
918 let parsed = parse_yaml(yaml).unwrap();
919 let routes = compile(parsed.routes).unwrap();
920 let hit = match_host(&routes, "github.com.fbi.com").unwrap();
921 assert_eq!(hit.target, "github.com:80");
922 }
923
924 #[test]
925 fn slug_kind_accepts_lowercase_and_dashes() {
926 let routes = compile(vec![RouteConfig {
927 name: "slugged".into(),
928 r#match: "{name:slug}.example".into(),
929 target: "{name}".into(),
930 headers: None,
931 }])
932 .unwrap();
933 assert!(match_host(&routes, "my-service.example").is_some());
934 assert!(match_host(&routes, "MY-SERVICE.example").is_some());
936 assert!(match_host(&routes, "my_service.example").is_none());
938 }
939
940 #[test]
941 fn parse_yaml_default_routes() {
942 let yaml = r#"
943version: 1
944routes:
945 - name: port-as-host
946 match: "{port:int}.{domain}"
947 target: "127.0.0.1:{port}"
948 - name: direct-forward
949 match: "{host}.{domain}"
950 target: "{host}:80"
951 headers:
952 Host: "{host}"
953"#;
954 let parsed = parse_yaml(yaml).expect("yaml should parse");
955 assert_eq!(parsed.version, 1);
956 assert_eq!(parsed.routes.len(), 2);
957 assert_eq!(parsed.routes[0].name, "port-as-host");
958 assert_eq!(parsed.routes[0].r#match, "{port:int}.{domain}");
959 assert_eq!(parsed.routes[1].headers.as_ref().unwrap()["Host"], "{host}");
960
961 let compiled = compile(parsed.routes).unwrap();
962 let hit = match_host(&compiled, "3000.fbi.com").unwrap();
963 assert_eq!(hit.target, "127.0.0.1:3000");
964 }
965
966 #[test]
967 fn expand_passes_through_unknown_placeholders_silently() {
968 let mut caps = HashMap::new();
973 caps.insert("a".to_string(), "X".to_string());
974 assert_eq!(expand("{a}-{b}", &caps), "X-");
975 }
976}