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 retain_available(
231 &mut self,
232 is_registered: impl Fn(&str) -> bool,
233 ) -> Vec<(String, String, String)> {
234 let mut skipped = Vec::new();
235 self.routes.retain(|route| {
236 let target = route.service.to_ascii_lowercase();
237 if target.starts_with("http://") || target.starts_with("https://") {
238 return true;
239 }
240 if is_registered(&route.service) {
241 return true;
242 }
243 skipped.push((
245 format!("[{}]", route.methods.join(", ")),
246 route.url.clone(),
247 route.service.clone(),
248 ));
249 false
250 });
251 skipped
252 }
253
254 pub fn static_content(&self) -> &StaticContent {
256 &self.static_content
257 }
258
259 pub fn from_yaml_text(yaml: &str) -> Result<Self, AppError> {
262 let value: serde_yaml::Value =
263 serde_yaml::from_str(yaml).map_err(|e| AppError::new(400, e.to_string()))?;
264 match ConfigValue::from_yaml(&value) {
265 ConfigValue::Map(map) => {
266 let reader = ConfigReader::from_map(map);
267 Self::load(&reader)
268 }
269 _ => Err(AppError::new(400, "rest.yaml text must be a YAML mapping")),
270 }
271 }
272
273 pub fn routes(&self) -> &[RouteInfo] {
274 &self.routes
275 }
276
277 pub fn has_url(&self, url: &str) -> bool {
280 self.routes.iter().any(|r| r.url.eq_ignore_ascii_case(url))
281 }
282
283 pub(crate) fn add_route(&mut self, route: RouteInfo) {
284 self.routes.push(route);
285 }
286
287 pub fn find(&self, method: &str, path: &str) -> Option<AssignedRoute<'_>> {
292 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
293 let mut best: Option<(usize, bool, AssignedRoute)> = None;
294 for info in &self.routes {
295 if method != "OPTIONS" && !info.methods.iter().any(|m| m == method) {
296 continue;
297 }
298 let Some(params) = info.match_path(&segments) else {
299 continue;
300 };
301 let literals = info
302 .segments
303 .iter()
304 .filter(|s| matches!(s, Segment::Literal(_)))
305 .count();
306 let wildcard = info.is_open_ended();
307 let better = match &best {
308 None => true,
309 Some((best_literals, best_wildcard, _)) => {
311 (!wildcard && *best_wildcard)
312 || (wildcard == *best_wildcard && literals > *best_literals)
313 }
314 };
315 if better {
316 best = Some((
317 literals,
318 wildcard,
319 AssignedRoute {
320 info,
321 path_params: params,
322 },
323 ));
324 }
325 }
326 best.map(|(_, _, assigned)| assigned)
327 }
328
329 pub fn path_matches_any_method(&self, path: &str) -> bool {
333 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
334 self.routes
335 .iter()
336 .any(|info| info.match_path(&segments).is_some())
337 }
338}
339
340impl RouteInfo {
341 fn is_open_ended(&self) -> bool {
347 matches!(
348 self.segments.last(),
349 Some(Segment::Any) | Some(Segment::Prefix(_))
350 )
351 }
352
353 fn match_path(&self, request_segments: &[&str]) -> Option<HashMap<String, String>> {
357 if self.is_open_ended() {
358 if self.segments.len() > request_segments.len() {
359 return None;
360 }
361 } else if self.segments.len() != request_segments.len() {
362 return None;
363 }
364 let mut params = HashMap::new();
365 for (i, segment) in self.segments.iter().enumerate() {
366 let actual = request_segments.get(i)?;
367 match segment {
368 Segment::Any => {}
369 Segment::Literal(expected) => {
370 if actual.to_lowercase() != *expected {
371 return None;
372 }
373 }
374 Segment::Prefix(prefix) => {
375 if !actual.to_lowercase().starts_with(prefix.as_str()) {
376 return None;
377 }
378 }
379 Segment::Param(name) => {
380 params.insert(name.clone(), actual.to_string());
381 }
382 }
383 }
384 Some(params)
385 }
386}
387
388fn lookup<'a>(
392 map: &'a std::collections::BTreeMap<String, ConfigValue>,
393 key: &str,
394) -> Option<&'a ConfigValue> {
395 if let Some(value) = map.get(key) {
396 return Some(value);
397 }
398 let mut parts = key.split('.');
399 let mut current = map.get(parts.next()?)?;
400 for part in parts {
401 match current {
402 ConfigValue::Map(nested) => current = nested.get(part)?,
403 _ => return None,
404 }
405 }
406 Some(current)
407}
408
409fn parse_route(
410 index: usize,
411 entry: &std::collections::BTreeMap<String, ConfigValue>,
412 cors_blocks: &HashMap<String, CorsInfo>,
413 header_blocks: &HashMap<String, HeaderInfo>,
414) -> Result<RouteInfo, AppError> {
415 let text = |key: &str| {
416 lookup(entry, key)
417 .and_then(|v| v.as_text())
418 .map(str::to_string)
419 };
420 let service = text("service")
422 .ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'service'")))?;
423 if service.starts_with("http://") || service.starts_with("https://") {
424 return Err(AppError::new(
425 400,
426 format!("rest[{index}] HTTP relay is not yet ported (service '{service}')"),
427 ));
428 }
429 let url =
430 text("url").ok_or_else(|| AppError::new(400, format!("rest[{index}] missing 'url'")))?;
431 let Some(ConfigValue::List(raw_methods)) = entry.get("methods") else {
432 return Err(AppError::new(
433 400,
434 format!("rest[{index}] missing 'methods' list"),
435 ));
436 };
437 let mut methods = Vec::new();
439 for method in raw_methods {
440 let method = method
441 .as_text()
442 .map(str::to_uppercase)
443 .ok_or_else(|| AppError::new(400, format!("rest[{index}] method must be text")))?;
444 if !ALLOWED_METHODS.contains(&method.as_str()) {
445 return Err(AppError::new(
446 400,
447 format!("rest[{index}] invalid method '{method}' (allowed: {ALLOWED_METHODS:?})"),
448 ));
449 }
450 methods.push(method);
451 }
452 let mut segments = Vec::new();
457 let parts: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
458 for part in &parts {
459 if let Some(name) = part.strip_prefix('{').and_then(|p| p.strip_suffix('}')) {
460 segments.push(Segment::Param(name.to_string()));
461 } else if *part == "*" {
462 segments.push(Segment::Any);
463 } else if let Some(prefix) = part.strip_suffix('*') {
464 segments.push(Segment::Prefix(prefix.to_lowercase()));
465 } else {
466 segments.push(Segment::Literal(part.to_lowercase()));
467 }
468 }
469 let cors =
471 match text("cors") {
472 Some(id) => Some(cors_blocks.get(&id).cloned().ok_or_else(|| {
473 AppError::new(400, format!("rest[{index}] unknown cors id '{id}'"))
474 })?),
475 None => None,
476 };
477 let headers = match text("headers") {
478 Some(id) => Some(header_blocks.get(&id).cloned().ok_or_else(|| {
479 AppError::new(400, format!("rest[{index}] unknown headers id '{id}'"))
480 })?),
481 None => None,
482 };
483 let tracing = matches!(entry.get("tracing"), Some(ConfigValue::Bool(true)));
484 let stream_response = matches!(entry.get("stream"), Some(ConfigValue::Bool(true)));
485 Ok(RouteInfo {
486 service,
487 methods,
488 url,
489 timeout: parse_timeout(text("timeout").as_deref()),
490 cors,
491 headers,
492 authentication: text("authentication"),
493 tracing,
494 trace_id_header: text("trace.id.header"),
495 correlation_id_header: text("correlation.id.header"),
496 traceparent_header: text("traceparent.header"),
497 flow: text("flow"),
498 stream_response,
499 segments,
500 })
501}
502
503pub(crate) fn parse_timeout(value: Option<&str>) -> Duration {
506 let parsed = value.and_then(|text| {
507 let text = text.trim().to_lowercase();
508 if let Some(ms) = text.strip_suffix("ms") {
509 ms.trim().parse::<u64>().ok().map(Duration::from_millis)
510 } else if let Some(minutes) = text.strip_suffix('m') {
511 minutes
512 .trim()
513 .parse::<u64>()
514 .ok()
515 .map(|m| Duration::from_secs(m * 60))
516 } else if let Some(seconds) = text.strip_suffix('s') {
517 seconds.trim().parse::<u64>().ok().map(Duration::from_secs)
518 } else {
519 text.parse::<u64>().ok().map(Duration::from_secs)
520 }
521 });
522 parsed
523 .unwrap_or(DEFAULT_TIMEOUT)
524 .clamp(MIN_TIMEOUT, MAX_TIMEOUT)
525}
526
527fn parse_cors_blocks(section: Option<&ConfigValue>) -> Result<HashMap<String, CorsInfo>, AppError> {
528 let mut blocks = HashMap::new();
529 let Some(ConfigValue::List(entries)) = section else {
530 return Ok(blocks);
531 };
532 for entry in entries {
533 let ConfigValue::Map(map) = entry else {
534 continue;
535 };
536 let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
537 return Err(AppError::new(400, "cors block missing 'id'"));
538 };
539 let mut info = CorsInfo {
540 id: id.to_string(),
541 ..CorsInfo::default()
542 };
543 info.options = parse_header_lines(map.get("options"), id, "options")?;
544 info.headers = parse_header_lines(map.get("headers"), id, "headers")?;
545 blocks.insert(info.id.clone(), info);
546 }
547 Ok(blocks)
548}
549
550fn parse_header_lines(
552 list: Option<&ConfigValue>,
553 id: &str,
554 kind: &str,
555) -> Result<Vec<(String, String)>, AppError> {
556 let mut out = Vec::new();
557 if let Some(ConfigValue::List(lines)) = list {
558 for line in lines {
559 let Some(line) = line.as_text() else { continue };
560 let Some((name, value)) = line.split_once(':') else {
561 return Err(AppError::new(
562 400,
563 format!("cors '{id}' {kind} line '{line}' is not 'name: value'"),
564 ));
565 };
566 let name = name.trim();
567 if !name.to_lowercase().starts_with("access-control-") {
568 return Err(AppError::new(
569 400,
570 format!("cors '{id}' {kind} line '{name}' must be an Access-Control-* header"),
571 ));
572 }
573 out.push((name.to_string(), value.trim().to_string()));
574 }
575 }
576 Ok(out)
577}
578
579fn parse_header_blocks(
580 section: Option<&ConfigValue>,
581) -> Result<HashMap<String, HeaderInfo>, AppError> {
582 let mut blocks = HashMap::new();
583 let Some(ConfigValue::List(entries)) = section else {
584 return Ok(blocks);
585 };
586 for entry in entries {
587 let ConfigValue::Map(map) = entry else {
588 continue;
589 };
590 let Some(id) = map.get("id").and_then(|v| v.as_text()) else {
591 return Err(AppError::new(400, "headers block missing 'id'"));
592 };
593 blocks.insert(
594 id.to_string(),
595 HeaderInfo {
596 id: id.to_string(),
597 request: parse_transform(map.get("request")),
598 response: parse_transform(map.get("response")),
599 },
600 );
601 }
602 Ok(blocks)
603}
604
605fn parse_transform(section: Option<&ConfigValue>) -> HeaderTransform {
606 let mut transform = HeaderTransform::default();
607 let Some(ConfigValue::Map(map)) = section else {
608 return transform;
609 };
610 if let Some(ConfigValue::List(add)) = map.get("add") {
611 for line in add {
612 if let Some((name, value)) = line.as_text().and_then(|l| l.split_once(':')) {
613 transform
614 .add
615 .push((name.trim().to_string(), value.trim().to_string()));
616 }
617 }
618 }
619 for (key, target) in [("drop", &mut transform.drop), ("keep", &mut transform.keep)] {
620 if let Some(ConfigValue::List(names)) = map.get(key) {
621 for name in names {
622 if let Some(name) = name.as_text() {
623 target.push(name.to_string());
624 }
625 }
626 }
627 }
628 transform
629}
630
631fn parse_static_content(reader: &ConfigReader) -> Result<StaticContent, AppError> {
635 let mut result = StaticContent::default();
636 if let Some(ConfigValue::List(pages)) = reader
637 .get_map()
638 .get_element("static-content.no-cache-pages")
639 {
640 let list: Vec<String> = pages
641 .iter()
642 .filter_map(|v| v.as_text().map(str::to_string))
643 .collect();
644 if valid_patterns(&list) && !list.is_empty() {
645 result.no_cache_pages = list;
646 } else {
647 return Err(AppError::new(
648 400,
649 "static-content.no-cache-pages has invalid syntax",
650 ));
651 }
652 }
653 let map = reader.get_map();
654 if map.key_exists("static-content.filter") {
655 let Some(ConfigValue::List(paths)) = map.get_element("static-content.filter.path") else {
656 return Err(AppError::new(
657 400,
658 "static-content.filter.path must be a list",
659 ));
660 };
661 let path_list: Vec<String> = paths
662 .iter()
663 .filter_map(|v| v.as_text().map(str::to_string))
664 .collect();
665 let service = map
666 .get_element("static-content.filter.service")
667 .and_then(|v| v.as_text())
668 .map(str::to_string)
669 .ok_or_else(|| AppError::new(400, "static-content.filter.service is required"))?;
670 let exclusion_list: Vec<String> = match map.get_element("static-content.filter.exclusion") {
671 Some(ConfigValue::List(items)) => items
672 .iter()
673 .filter_map(|v| v.as_text().map(str::to_string))
674 .collect(),
675 _ => Vec::new(),
676 };
677 if path_list.is_empty() || !valid_patterns(&path_list) || !valid_patterns(&exclusion_list) {
678 return Err(AppError::new(
679 400,
680 "static-content.filter path/exclusion has invalid syntax",
681 ));
682 }
683 log::info!("static-content.filter loaded: {path_list:?} -> {service}, exclusion {exclusion_list:?}");
684 result.filter = Some(SimpleHttpFilter {
685 path_list,
686 exclusion_list,
687 service,
688 });
689 }
690 Ok(result)
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 fn table(yaml: &str) -> Result<RoutingTable, AppError> {
698 let dir = std::env::temp_dir().join(format!("pc-rest-{}", uuid::Uuid::new_v4().simple()));
699 std::fs::create_dir_all(&dir).unwrap();
700 let file = dir.join("rest.yaml");
701 std::fs::write(&file, yaml).unwrap();
702 let reader = ConfigReader::load(&format!("file:{}", file.display()))
703 .map_err(|e| AppError::new(400, e.to_string()))?;
704 let result = RoutingTable::load(&reader);
705 std::fs::remove_dir_all(&dir).ok();
706 result
707 }
708
709 const VALID: &str = r#"
710rest:
711 - service: "greeting.api"
712 methods: ['GET']
713 url: "/api/greeting/{user}"
714 timeout: 10s
715 cors: cors_1
716 headers: header_1
717 tracing: true
718 - service: "catch.all"
719 methods: ['GET', 'POST']
720 url: "/api/files/*"
721 - service: "exact.match"
722 methods: ['GET']
723 url: "/api/greeting/system"
724cors:
725 - id: cors_1
726 options:
727 - "Access-Control-Allow-Origin: *"
728 - "Access-Control-Allow-Methods: GET, POST, OPTIONS"
729 headers:
730 - "Access-Control-Allow-Origin: *"
731headers:
732 - id: header_1
733 request:
734 drop: ['x-secret']
735 response:
736 add: ["x-served-by: mercury"]
737"#;
738
739 #[test]
740 fn parses_valid_rest_yaml() {
741 let table = table(VALID).unwrap();
742 assert_eq!(table.routes().len(), 3);
743 let route = &table.routes()[0];
744 assert_eq!(route.service, "greeting.api");
745 assert_eq!(route.timeout, Duration::from_secs(10));
746 assert!(route.tracing);
747 assert!(route.cors.is_some());
748 assert!(route.headers.is_some());
749 }
750
751 #[test]
752 fn retain_available_drops_only_unregistered_function_routes() {
753 let mut t = table(
754 "rest:\n - service: keep.me\n methods: ['GET']\n url: /a\n - service: drop.me\n methods: ['GET', 'POST']\n url: /b\n",
755 )
756 .unwrap();
757 let skipped = t.retain_available(|service| service == "keep.me");
758 assert_eq!(
759 skipped,
760 vec![(
761 "[GET, POST]".to_string(),
762 "/b".to_string(),
763 "drop.me".to_string()
764 )]
765 );
766 let left: Vec<&str> = t.routes().iter().map(|r| r.service.as_str()).collect();
767 assert_eq!(left, vec!["keep.me"]);
768 }
769
770 #[test]
771 fn match_precedence_exact_then_param_then_wildcard() {
772 let table = table(VALID).unwrap();
773 let hit = table.find("GET", "/api/greeting/system").unwrap();
775 assert_eq!(hit.info.service, "exact.match");
776 let hit = table.find("GET", "/API/Greeting/Eric").unwrap();
778 assert_eq!(hit.info.service, "greeting.api");
779 assert_eq!(hit.path_params["user"], "Eric");
780 let hit = table.find("POST", "/api/files/a/b/c").unwrap();
782 assert_eq!(hit.info.service, "catch.all");
783 assert!(table.find("DELETE", "/api/greeting/eric").is_none());
785 assert!(table.find("OPTIONS", "/api/greeting/eric").is_some());
787 assert!(table.find("GET", "/api/unknown").is_none());
789 }
790
791 #[test]
792 fn parser_invariants_are_enforced() {
793 assert!(table("rest:\n - service: x.y\n methods: ['FETCH']\n url: /a\n").is_err());
795 assert!(table(
797 "rest:\n - service: x.y\n methods: ['GET']\n url: /a\n cors: nope\n"
798 )
799 .is_err());
800 assert!(table(
802 "rest:\n - service: 'https://example.com'\n methods: ['GET']\n url: /a\n"
803 )
804 .is_err());
805 assert!(
808 table("rest:\n - service: x.y\n methods: ['GET']\n url: '/a/*/b'\n").is_ok()
809 );
810 assert!(table(
812 "rest:\n - service: x.y\n methods: ['GET']\n url: /a\ncors:\n - id: c1\n options:\n - 'X-Other: 1'\n"
813 )
814 .is_err());
815 }
816
817 #[test]
818 fn timeout_parse_and_clamp() {
819 assert_eq!(parse_timeout(Some("10s")), Duration::from_secs(10));
820 assert_eq!(parse_timeout(Some("2m")), Duration::from_secs(120));
821 assert_eq!(parse_timeout(Some("1500ms")), Duration::from_millis(1500));
822 assert_eq!(parse_timeout(Some("500ms")), MIN_TIMEOUT); assert_eq!(parse_timeout(None), DEFAULT_TIMEOUT);
824 assert_eq!(parse_timeout(Some("0s")), MIN_TIMEOUT); assert_eq!(parse_timeout(Some("30m")), MAX_TIMEOUT); assert_eq!(parse_timeout(Some("garbage")), DEFAULT_TIMEOUT);
827 }
828
829 #[test]
830 fn header_transform_keep_drop_add() {
831 let transform = HeaderTransform {
832 add: vec![("x-served-by".into(), "mercury".into())],
833 drop: vec!["X-Secret".into()],
834 keep: vec![],
835 };
836 let mut headers: HashMap<String, String> = HashMap::from([
837 ("x-secret".into(), "shh".into()),
838 ("accept".into(), "*/*".into()),
839 ]);
840 transform.apply(&mut headers);
841 assert!(!headers.contains_key("x-secret"));
842 assert_eq!(headers["x-served-by"], "mercury");
843 assert_eq!(headers["accept"], "*/*");
844 let keep_only = HeaderTransform {
845 keep: vec!["Accept".into()],
846 ..HeaderTransform::default()
847 };
848 let mut headers: HashMap<String, String> = HashMap::from([
849 ("accept".into(), "*/*".into()),
850 ("x-noise".into(), "1".into()),
851 ]);
852 keep_only.apply(&mut headers);
853 assert_eq!(headers.len(), 1);
854 assert!(headers.contains_key("accept"));
855 }
856}