1use std::collections::HashMap;
29use std::time::Duration;
30
31use crate::function::AppError;
32use crate::util::config_reader::ConfigReader;
33use crate::util::multi_level_map::ConfigValue;
34
35const ALLOWED_METHODS: [&str; 6] = ["GET", "PUT", "POST", "DELETE", "HEAD", "PATCH"];
36const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
37const MIN_TIMEOUT: Duration = Duration::from_secs(1);
38const MAX_TIMEOUT: Duration = Duration::from_secs(300); #[derive(Clone, Debug)]
42pub struct RouteInfo {
43 pub service: String,
44 pub methods: Vec<String>,
45 pub url: String,
47 pub timeout: Duration,
48 pub cors: Option<CorsInfo>,
49 pub headers: Option<HeaderInfo>,
50 pub authentication: Option<String>,
52 pub tracing: bool,
53 pub trace_id_header: Option<String>,
56 pub correlation_id_header: Option<String>,
57 pub traceparent_header: Option<String>,
58 pub flow: Option<String>,
61 pub stream_response: bool,
65 segments: Vec<Segment>,
66}
67
68#[derive(Clone, Debug)]
69enum Segment {
70 Literal(String),
72 Param(String),
74 Any,
77 Prefix(String),
81}
82
83#[derive(Clone, Debug, Default)]
85pub struct CorsInfo {
86 pub id: String,
87 pub options: Vec<(String, String)>,
89 pub headers: Vec<(String, String)>,
91}
92
93#[derive(Clone, Debug, Default)]
96pub struct HeaderInfo {
97 pub id: String,
98 pub request: HeaderTransform,
99 pub response: HeaderTransform,
100}
101
102#[derive(Clone, Debug, Default)]
103pub struct HeaderTransform {
104 pub add: Vec<(String, String)>,
105 pub drop: Vec<String>,
106 pub keep: Vec<String>,
107}
108
109impl HeaderTransform {
110 pub fn apply(&self, headers: &mut HashMap<String, String>) {
114 if !self.keep.is_empty() {
115 let keep: Vec<String> = self.keep.iter().map(|k| k.to_lowercase()).collect();
116 headers.retain(|name, _| keep.contains(&name.to_lowercase()));
117 }
118 for name in &self.drop {
119 let name = name.to_lowercase();
120 headers.retain(|existing, _| existing.to_lowercase() != name);
121 }
122 for (name, value) in &self.add {
123 headers.insert(name.clone(), value.clone());
124 }
125 }
126}
127
128#[derive(Clone, Debug)]
131pub struct StaticContent {
132 pub no_cache_pages: Vec<String>,
136 pub filter: Option<SimpleHttpFilter>,
137}
138
139impl Default for StaticContent {
140 fn default() -> Self {
141 StaticContent {
142 no_cache_pages: vec!["/".to_string(), "/index.html".to_string()],
143 filter: None,
144 }
145 }
146}
147
148#[derive(Clone, Debug)]
152pub struct SimpleHttpFilter {
153 pub path_list: Vec<String>,
154 pub exclusion_list: Vec<String>,
155 pub service: String,
156}
157
158pub fn matched_element(elements: &[String], path: &str) -> bool {
161 elements.iter().any(|pattern| {
162 if let Some(prefix) = pattern.strip_suffix('*') {
163 path.starts_with(prefix)
164 } else if let Some(suffix) = pattern.strip_prefix('*') {
165 path.ends_with(suffix)
166 } else {
167 path == pattern
168 }
169 })
170}
171
172fn valid_patterns(patterns: &[String]) -> bool {
175 patterns.iter().all(|p| {
176 !p.is_empty()
177 && match p.matches('*').count() {
178 0 => true,
179 1 => p.starts_with('*') || p.ends_with('*'),
180 _ => false,
181 }
182 })
183}
184
185pub struct RoutingTable {
187 routes: Vec<RouteInfo>,
188 static_content: StaticContent,
189}
190
191pub struct AssignedRoute<'a> {
193 pub info: &'a RouteInfo,
194 pub path_params: HashMap<String, String>,
195}
196
197impl RoutingTable {
198 pub fn load(reader: &ConfigReader) -> Result<Self, AppError> {
201 let map = reader.get_map();
202 let cors_blocks = parse_cors_blocks(map.get_element("cors"))?;
204 let header_blocks = parse_header_blocks(map.get_element("headers"))?;
205 let Some(ConfigValue::List(entries)) = map.get_element("rest") else {
206 return Err(AppError::new(400, "rest.yaml has no 'rest' section"));
207 };
208 let mut routes = Vec::new();
209 for (index, entry) in entries.iter().enumerate() {
210 let ConfigValue::Map(entry) = entry else {
211 return Err(AppError::new(400, format!("rest[{index}] is not a map")));
212 };
213 routes.push(parse_route(index, entry, &cors_blocks, &header_blocks)?);
214 }
215 let static_content = parse_static_content(reader)?;
216 Ok(RoutingTable {
217 routes,
218 static_content,
219 })
220 }
221
222 pub fn static_content(&self) -> &StaticContent {
224 &self.static_content
225 }
226
227 pub fn from_yaml_text(yaml: &str) -> Result<Self, AppError> {
230 let value: serde_yaml::Value =
231 serde_yaml::from_str(yaml).map_err(|e| AppError::new(400, e.to_string()))?;
232 match ConfigValue::from_yaml(&value) {
233 ConfigValue::Map(map) => {
234 let reader = ConfigReader::from_map(map);
235 Self::load(&reader)
236 }
237 _ => Err(AppError::new(400, "rest.yaml text must be a YAML mapping")),
238 }
239 }
240
241 pub fn routes(&self) -> &[RouteInfo] {
242 &self.routes
243 }
244
245 pub fn has_url(&self, url: &str) -> bool {
248 self.routes.iter().any(|r| r.url.eq_ignore_ascii_case(url))
249 }
250
251 pub(crate) fn add_route(&mut self, route: RouteInfo) {
252 self.routes.push(route);
253 }
254
255 pub fn find(&self, method: &str, path: &str) -> Option<AssignedRoute<'_>> {
260 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
261 let mut best: Option<(usize, bool, AssignedRoute)> = None;
262 for info in &self.routes {
263 if method != "OPTIONS" && !info.methods.iter().any(|m| m == method) {
264 continue;
265 }
266 let Some(params) = info.match_path(&segments) else {
267 continue;
268 };
269 let literals = info
270 .segments
271 .iter()
272 .filter(|s| matches!(s, Segment::Literal(_)))
273 .count();
274 let wildcard = info.is_open_ended();
275 let better = match &best {
276 None => true,
277 Some((best_literals, best_wildcard, _)) => {
279 (!wildcard && *best_wildcard)
280 || (wildcard == *best_wildcard && literals > *best_literals)
281 }
282 };
283 if better {
284 best = Some((
285 literals,
286 wildcard,
287 AssignedRoute {
288 info,
289 path_params: params,
290 },
291 ));
292 }
293 }
294 best.map(|(_, _, assigned)| assigned)
295 }
296
297 pub fn path_matches_any_method(&self, path: &str) -> bool {
301 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
302 self.routes
303 .iter()
304 .any(|info| info.match_path(&segments).is_some())
305 }
306}
307
308impl RouteInfo {
309 fn is_open_ended(&self) -> bool {
315 matches!(
316 self.segments.last(),
317 Some(Segment::Any) | Some(Segment::Prefix(_))
318 )
319 }
320
321 fn match_path(&self, request_segments: &[&str]) -> Option<HashMap<String, String>> {
325 if self.is_open_ended() {
326 if self.segments.len() > request_segments.len() {
327 return None;
328 }
329 } else if self.segments.len() != request_segments.len() {
330 return None;
331 }
332 let mut params = HashMap::new();
333 for (i, segment) in self.segments.iter().enumerate() {
334 let actual = request_segments.get(i)?;
335 match segment {
336 Segment::Any => {}
337 Segment::Literal(expected) => {
338 if actual.to_lowercase() != *expected {
339 return None;
340 }
341 }
342 Segment::Prefix(prefix) => {
343 if !actual.to_lowercase().starts_with(prefix.as_str()) {
344 return None;
345 }
346 }
347 Segment::Param(name) => {
348 params.insert(name.clone(), actual.to_string());
349 }
350 }
351 }
352 Some(params)
353 }
354}
355
356fn lookup<'a>(
360 map: &'a std::collections::BTreeMap<String, ConfigValue>,
361 key: &str,
362) -> Option<&'a ConfigValue> {
363 if let Some(value) = map.get(key) {
364 return Some(value);
365 }
366 let mut parts = key.split('.');
367 let mut current = map.get(parts.next()?)?;
368 for part in parts {
369 match current {
370 ConfigValue::Map(nested) => current = nested.get(part)?,
371 _ => return None,
372 }
373 }
374 Some(current)
375}
376
377fn parse_route(
378 index: usize,
379 entry: &std::collections::BTreeMap<String, ConfigValue>,
380 cors_blocks: &HashMap<String, CorsInfo>,
381 header_blocks: &HashMap<String, HeaderInfo>,
382) -> Result<RouteInfo, AppError> {
383 let text = |key: &str| {
384 lookup(entry, key)
385 .and_then(|v| v.as_text())
386 .map(str::to_string)
387 };
388 let service = text("service")
390 .ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'service'")))?;
391 if service.starts_with("http://") || service.starts_with("https://") {
392 return Err(AppError::new(
393 400,
394 format!("rest[{index}] HTTP relay is not yet ported (service '{service}')"),
395 ));
396 }
397 let url =
398 text("url").ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'url'")))?;
399 let Some(ConfigValue::List(raw_methods)) = entry.get("methods") else {
400 return Err(AppError::new(
401 400,
402 format!("rest[{index}] missing 'methods' list"),
403 ));
404 };
405 let mut methods = Vec::new();
407 for method in raw_methods {
408 let method = method
409 .as_text()
410 .map(str::to_uppercase)
411 .ok_or_else(|| AppError::new(400, format!("rest[{index}] method must be text")))?;
412 if !ALLOWED_METHODS.contains(&method.as_str()) {
413 return Err(AppError::new(
414 400,
415 format!("rest[{index}] invalid method '{method}' (allowed: {ALLOWED_METHODS:?})"),
416 ));
417 }
418 methods.push(method);
419 }
420 let mut segments = Vec::new();
425 let parts: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
426 for part in &parts {
427 if let Some(name) = part.strip_prefix('{').and_then(|p| p.strip_suffix('}')) {
428 segments.push(Segment::Param(name.to_string()));
429 } else if *part == "*" {
430 segments.push(Segment::Any);
431 } else if let Some(prefix) = part.strip_suffix('*') {
432 segments.push(Segment::Prefix(prefix.to_lowercase()));
433 } else {
434 segments.push(Segment::Literal(part.to_lowercase()));
435 }
436 }
437 let cors =
439 match text("cors") {
440 Some(id) => Some(cors_blocks.get(&id).cloned().ok_or_else(|| {
441 AppError::new(400, format!("rest[{index}] unknown cors id '{id}'"))
442 })?),
443 None => None,
444 };
445 let headers = match text("headers") {
446 Some(id) => Some(header_blocks.get(&id).cloned().ok_or_else(|| {
447 AppError::new(400, format!("rest[{index}] unknown headers id '{id}'"))
448 })?),
449 None => None,
450 };
451 let tracing = matches!(entry.get("tracing"), Some(ConfigValue::Bool(true)));
452 let stream_response = matches!(entry.get("stream"), Some(ConfigValue::Bool(true)));
453 Ok(RouteInfo {
454 service,
455 methods,
456 url,
457 timeout: parse_timeout(text("timeout").as_deref()),
458 cors,
459 headers,
460 authentication: text("authentication"),
461 tracing,
462 trace_id_header: text("trace.id.header"),
463 correlation_id_header: text("correlation.id.header"),
464 traceparent_header: text("traceparent.header"),
465 flow: text("flow"),
466 stream_response,
467 segments,
468 })
469}
470
471pub(crate) fn parse_timeout(value: Option<&str>) -> Duration {
474 let parsed = value.and_then(|text| {
475 let text = text.trim().to_lowercase();
476 if let Some(ms) = text.strip_suffix("ms") {
477 ms.trim().parse::<u64>().ok().map(Duration::from_millis)
478 } else if let Some(minutes) = text.strip_suffix('m') {
479 minutes
480 .trim()
481 .parse::<u64>()
482 .ok()
483 .map(|m| Duration::from_secs(m * 60))
484 } else if let Some(seconds) = text.strip_suffix('s') {
485 seconds.trim().parse::<u64>().ok().map(Duration::from_secs)
486 } else {
487 text.parse::<u64>().ok().map(Duration::from_secs)
488 }
489 });
490 parsed
491 .unwrap_or(DEFAULT_TIMEOUT)
492 .clamp(MIN_TIMEOUT, MAX_TIMEOUT)
493}
494
495fn parse_cors_blocks(section: Option<&ConfigValue>) -> Result<HashMap<String, CorsInfo>, AppError> {
496 let mut blocks = HashMap::new();
497 let Some(ConfigValue::List(entries)) = section else {
498 return Ok(blocks);
499 };
500 for entry in entries {
501 let ConfigValue::Map(map) = entry else {
502 continue;
503 };
504 let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
505 return Err(AppError::new(400, "cors block missing 'id'"));
506 };
507 let mut info = CorsInfo {
508 id: id.to_string(),
509 ..CorsInfo::default()
510 };
511 info.options = parse_header_lines(map.get("options"), id, "options")?;
512 info.headers = parse_header_lines(map.get("headers"), id, "headers")?;
513 blocks.insert(info.id.clone(), info);
514 }
515 Ok(blocks)
516}
517
518fn parse_header_lines(
520 list: Option<&ConfigValue>,
521 id: &str,
522 kind: &str,
523) -> Result<Vec<(String, String)>, AppError> {
524 let mut out = Vec::new();
525 if let Some(ConfigValue::List(lines)) = list {
526 for line in lines {
527 let Some(line) = line.as_text() else { continue };
528 let Some((name, value)) = line.split_once(':') else {
529 return Err(AppError::new(
530 400,
531 format!("cors '{id}' {kind} line '{line}' is not 'name: value'"),
532 ));
533 };
534 let name = name.trim();
535 if !name.to_lowercase().starts_with("access-control-") {
536 return Err(AppError::new(
537 400,
538 format!("cors '{id}' {kind} line '{name}' must be an Access-Control-* header"),
539 ));
540 }
541 out.push((name.to_string(), value.trim().to_string()));
542 }
543 }
544 Ok(out)
545}
546
547fn parse_header_blocks(
548 section: Option<&ConfigValue>,
549) -> Result<HashMap<String, HeaderInfo>, AppError> {
550 let mut blocks = HashMap::new();
551 let Some(ConfigValue::List(entries)) = section else {
552 return Ok(blocks);
553 };
554 for entry in entries {
555 let ConfigValue::Map(map) = entry else {
556 continue;
557 };
558 let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
559 return Err(AppError::new(400, "headers block missing 'id'"));
560 };
561 blocks.insert(
562 id.to_string(),
563 HeaderInfo {
564 id: id.to_string(),
565 request: parse_transform(map.get("request")),
566 response: parse_transform(map.get("response")),
567 },
568 );
569 }
570 Ok(blocks)
571}
572
573fn parse_transform(section: Option<&ConfigValue>) -> HeaderTransform {
574 let mut transform = HeaderTransform::default();
575 let Some(ConfigValue::Map(map)) = section else {
576 return transform;
577 };
578 if let Some(ConfigValue::List(add)) = map.get("add") {
579 for line in add {
580 if let Some((name, value)) = line.as_text().and_then(|l| l.split_once(':')) {
581 transform
582 .add
583 .push((name.trim().to_string(), value.trim().to_string()));
584 }
585 }
586 }
587 for (key, target) in [("drop", &mut transform.drop), ("keep", &mut transform.keep)] {
588 if let Some(ConfigValue::List(names)) = map.get(key) {
589 for name in names {
590 if let Some(name) = name.as_text() {
591 target.push(name.to_string());
592 }
593 }
594 }
595 }
596 transform
597}
598
599fn parse_static_content(reader: &ConfigReader) -> Result<StaticContent, AppError> {
603 let mut result = StaticContent::default();
604 if let Some(ConfigValue::List(pages)) = reader
605 .get_map()
606 .get_element("static-content.no-cache-pages")
607 {
608 let list: Vec<String> = pages
609 .iter()
610 .filter_map(|v| v.as_text().map(str::to_string))
611 .collect();
612 if valid_patterns(&list) && !list.is_empty() {
613 result.no_cache_pages = list;
614 } else {
615 return Err(AppError::new(
616 400,
617 "static-content.no-cache-pages has invalid syntax",
618 ));
619 }
620 }
621 let map = reader.get_map();
622 if map.key_exists("static-content.filter") {
623 let Some(ConfigValue::List(paths)) = map.get_element("static-content.filter.path") else {
624 return Err(AppError::new(
625 400,
626 "static-content.filter.path must be a list",
627 ));
628 };
629 let path_list: Vec<String> = paths
630 .iter()
631 .filter_map(|v| v.as_text().map(str::to_string))
632 .collect();
633 let service = map
634 .get_element("static-content.filter.service")
635 .and_then(|v| v.as_text())
636 .map(str::to_string)
637 .ok_or_else(|| AppError::new(400, "static-content.filter.service is required"))?;
638 let exclusion_list: Vec<String> = match map.get_element("static-content.filter.exclusion") {
639 Some(ConfigValue::List(items)) => items
640 .iter()
641 .filter_map(|v| v.as_text().map(str::to_string))
642 .collect(),
643 _ => Vec::new(),
644 };
645 if path_list.is_empty() || !valid_patterns(&path_list) || !valid_patterns(&exclusion_list) {
646 return Err(AppError::new(
647 400,
648 "static-content.filter path/exclusion has invalid syntax",
649 ));
650 }
651 log::info!("static-content.filter loaded: {path_list:?} -> {service}, exclusion {exclusion_list:?}");
652 result.filter = Some(SimpleHttpFilter {
653 path_list,
654 exclusion_list,
655 service,
656 });
657 }
658 Ok(result)
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 fn table(yaml: &str) -> Result<RoutingTable, AppError> {
666 let dir = std::env::temp_dir().join(format!("pc-rest-{}", uuid::Uuid::new_v4().simple()));
667 std::fs::create_dir_all(&dir).unwrap();
668 let file = dir.join("rest.yaml");
669 std::fs::write(&file, yaml).unwrap();
670 let reader = ConfigReader::load(&format!("file:{}", file.display()))
671 .map_err(|e| AppError::new(400, e.to_string()))?;
672 let result = RoutingTable::load(&reader);
673 std::fs::remove_dir_all(&dir).ok();
674 result
675 }
676
677 const VALID: &str = r#"
678rest:
679 - service: "greeting.api"
680 methods: ['GET']
681 url: "/api/greeting/{user}"
682 timeout: 10s
683 cors: cors_1
684 headers: header_1
685 tracing: true
686 - service: "catch.all"
687 methods: ['GET', 'POST']
688 url: "/api/files/*"
689 - service: "exact.match"
690 methods: ['GET']
691 url: "/api/greeting/system"
692cors:
693 - id: cors_1
694 options:
695 - "Access-Control-Allow-Origin: *"
696 - "Access-Control-Allow-Methods: GET, POST, OPTIONS"
697 headers:
698 - "Access-Control-Allow-Origin: *"
699headers:
700 - id: header_1
701 request:
702 drop: ['x-secret']
703 response:
704 add: ["x-served-by: mercury"]
705"#;
706
707 #[test]
708 fn parses_valid_rest_yaml() {
709 let table = table(VALID).unwrap();
710 assert_eq!(table.routes().len(), 3);
711 let route = &table.routes()[0];
712 assert_eq!(route.service, "greeting.api");
713 assert_eq!(route.timeout, Duration::from_secs(10));
714 assert!(route.tracing);
715 assert!(route.cors.is_some());
716 assert!(route.headers.is_some());
717 }
718
719 #[test]
720 fn match_precedence_exact_then_param_then_wildcard() {
721 let table = table(VALID).unwrap();
722 let hit = table.find("GET", "/api/greeting/system").unwrap();
724 assert_eq!(hit.info.service, "exact.match");
725 let hit = table.find("GET", "/API/Greeting/Eric").unwrap();
727 assert_eq!(hit.info.service, "greeting.api");
728 assert_eq!(hit.path_params["user"], "Eric");
729 let hit = table.find("POST", "/api/files/a/b/c").unwrap();
731 assert_eq!(hit.info.service, "catch.all");
732 assert!(table.find("DELETE", "/api/greeting/eric").is_none());
734 assert!(table.find("OPTIONS", "/api/greeting/eric").is_some());
736 assert!(table.find("GET", "/api/unknown").is_none());
738 }
739
740 #[test]
741 fn parser_invariants_are_enforced() {
742 assert!(table("rest:\n - service: x.y\n methods: ['FETCH']\n url: /a\n").is_err());
744 assert!(table(
746 "rest:\n - service: x.y\n methods: ['GET']\n url: /a\n cors: nope\n"
747 )
748 .is_err());
749 assert!(table(
751 "rest:\n - service: 'https://example.com'\n methods: ['GET']\n url: /a\n"
752 )
753 .is_err());
754 assert!(
757 table("rest:\n - service: x.y\n methods: ['GET']\n url: '/a/*/b'\n").is_ok()
758 );
759 assert!(table(
761 "rest:\n - service: x.y\n methods: ['GET']\n url: /a\ncors:\n - id: c1\n options:\n - 'X-Other: 1'\n"
762 )
763 .is_err());
764 }
765
766 #[test]
767 fn timeout_parse_and_clamp() {
768 assert_eq!(parse_timeout(Some("10s")), Duration::from_secs(10));
769 assert_eq!(parse_timeout(Some("2m")), Duration::from_secs(120));
770 assert_eq!(parse_timeout(Some("1500ms")), Duration::from_millis(1500));
771 assert_eq!(parse_timeout(Some("500ms")), MIN_TIMEOUT); assert_eq!(parse_timeout(None), DEFAULT_TIMEOUT);
773 assert_eq!(parse_timeout(Some("0s")), MIN_TIMEOUT); assert_eq!(parse_timeout(Some("30m")), MAX_TIMEOUT); assert_eq!(parse_timeout(Some("garbage")), DEFAULT_TIMEOUT);
776 }
777
778 #[test]
779 fn header_transform_keep_drop_add() {
780 let transform = HeaderTransform {
781 add: vec![("x-served-by".into(), "mercury".into())],
782 drop: vec!["X-Secret".into()],
783 keep: vec![],
784 };
785 let mut headers: HashMap<String, String> = HashMap::from([
786 ("x-secret".into(), "shh".into()),
787 ("accept".into(), "*/*".into()),
788 ]);
789 transform.apply(&mut headers);
790 assert!(!headers.contains_key("x-secret"));
791 assert_eq!(headers["x-served-by"], "mercury");
792 assert_eq!(headers["accept"], "*/*");
793 let keep_only = HeaderTransform {
794 keep: vec!["Accept".into()],
795 ..HeaderTransform::default()
796 };
797 let mut headers: HashMap<String, String> = HashMap::from([
798 ("accept".into(), "*/*".into()),
799 ("x-noise".into(), "1".into()),
800 ]);
801 keep_only.apply(&mut headers);
802 assert_eq!(headers.len(), 1);
803 assert!(headers.contains_key("accept"));
804 }
805}