1use std::{any::Any, borrow::Cow, collections::HashMap, net::IpAddr, sync::Arc, time::Instant};
7
8use http::{HeaderMap, Method, StatusCode, Uri};
9use praxis_core::{
10 connectivity::Upstream, health::HealthRegistry, id::IdGenerator, kv::KvStoreRegistry, time::TimeSource,
11};
12
13use crate::{body::BodyMode, extensions::RequestExtensions, pipeline::body::merge_body_mode, results::FilterResultSet};
14
15const MAX_METADATA_ENTRIES: usize = 128;
25
26pub struct HttpFilterContext<'a> {
35 pub body_done_indices: Vec<bool>,
38
39 pub branch_iterations: HashMap<Arc<str>, u32>,
42
43 pub client_addr: Option<IpAddr>,
45
46 pub cluster: Option<Arc<str>>,
48
49 pub current_filter_id: Option<usize>,
60
61 pub downstream_tls: bool,
69
70 pub extensions: RequestExtensions,
81
82 pub executed_filter_indices: Vec<bool>,
86
87 pub extra_request_headers: Vec<(Cow<'static, str>, String)>,
89
90 pub request_headers_to_remove: Vec<http::header::HeaderName>,
92
93 pub request_headers_to_set: Vec<(http::header::HeaderName, http::header::HeaderValue)>,
95
96 pub filter_metadata: HashMap<String, String>,
107
108 pub filter_results: HashMap<&'static str, FilterResultSet>,
115
116 pub filter_state: HashMap<usize, Box<dyn Any + Send + Sync>>,
128
129 pub health_registry: Option<&'a HealthRegistry>,
131
132 pub id_generator: &'a IdGenerator,
134
135 pub kv_stores: Option<&'a KvStoreRegistry>,
137
138 pub request: &'a Request,
140
141 pub request_body_bytes: u64,
143
144 pub request_body_mode: BodyMode,
150
151 pub request_start: Instant,
153
154 pub response_body_bytes: u64,
156
157 pub response_body_mode: BodyMode,
163
164 pub response_header: Option<&'a mut Response>,
167
168 pub response_headers_modified: bool,
171
172 pub selected_endpoint_index: Option<usize>,
177
178 pub time_source: &'a dyn TimeSource,
180
181 pub rewritten_path: Option<String>,
197
198 pub upstream: Option<Upstream>,
200}
201
202impl HttpFilterContext<'_> {
203 pub fn cluster_name(&self) -> Option<&str> {
205 self.cluster.as_deref()
206 }
207
208 pub fn upstream_addr(&self) -> Option<&str> {
210 self.upstream.as_ref().map(|u| &*u.address)
211 }
212
213 pub fn get_metadata(&self, key: &str) -> Option<&str> {
215 self.filter_metadata.get(key).map(String::as_str)
216 }
217
218 pub fn request_id(&self) -> Option<&str> {
220 self.request.headers.get("x-request-id").and_then(|v| v.to_str().ok())
221 }
222
223 pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
230 let key = key.into();
231 let value = value.into();
232 if key.is_empty() || key.len() > 64 {
233 tracing::warn!(key_len = key.len(), "metadata key rejected (must be 1-64 bytes)");
234 return;
235 }
236 if value.len() > 256 {
237 tracing::warn!(key = %key, value_len = value.len(), "metadata value rejected (max 256 bytes)");
238 return;
239 }
240 if !self.filter_metadata.contains_key(&key) && self.filter_metadata.len() >= MAX_METADATA_ENTRIES {
241 tracing::warn!(
242 key = %key,
243 entries = self.filter_metadata.len(),
244 "metadata entry rejected (max {MAX_METADATA_ENTRIES} entries)"
245 );
246 return;
247 }
248 self.filter_metadata.insert(key, value);
249 }
250
251 pub fn set_request_body_mode(&mut self, mode: BodyMode) {
257 merge_body_mode(&mut self.request_body_mode, mode);
258 }
259
260 pub fn set_response_body_mode(&mut self, mode: BodyMode) {
266 merge_body_mode(&mut self.response_body_mode, mode);
267 }
268
269 pub fn insert_filter_state<T: Any + Send + Sync>(&mut self, state: T) {
279 let Some(idx) = self.current_filter_id else {
280 tracing::warn!("insert_filter_state called outside pipeline execution");
281 return;
282 };
283 self.filter_state.insert(idx, Box::new(state));
284 }
285
286 pub fn get_filter_state<T: Any + Send + Sync>(&self) -> Option<&T> {
292 let idx = self.current_filter_id?;
293 self.filter_state.get(&idx)?.downcast_ref()
294 }
295
296 pub fn get_filter_state_mut<T: Any + Send + Sync>(&mut self) -> Option<&mut T> {
304 let idx = self.current_filter_id?;
305 self.filter_state.get_mut(&idx)?.downcast_mut()
306 }
307
308 pub fn remove_filter_state<T: Any + Send + Sync>(&mut self) -> Option<T> {
315 let idx = self.current_filter_id?;
316 if !self.filter_state.get(&idx)?.as_ref().is::<T>() {
317 return None;
318 }
319 let boxed = self.filter_state.remove(&idx)?;
320 Some(*boxed.downcast::<T>().ok()?)
321 }
322}
323
324#[derive(Debug, Clone)]
342pub struct Request {
343 pub headers: HeaderMap,
345
346 pub method: Method,
348
349 pub uri: Uri,
351}
352
353#[derive(Debug)]
371pub struct Response {
372 pub headers: HeaderMap,
374
375 pub status: StatusCode,
377}
378
379#[cfg(test)]
384#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
385#[allow(
386 clippy::unwrap_used,
387 clippy::expect_used,
388 clippy::indexing_slicing,
389 clippy::panic,
390 reason = "tests"
391)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn request_fields_are_accessible() {
397 let req = Request {
398 method: Method::POST,
399 uri: "/submit".parse().unwrap(),
400 headers: HeaderMap::new(),
401 };
402 assert_eq!(req.method, Method::POST);
403 assert_eq!(req.uri.path(), "/submit");
404 assert!(req.headers.is_empty(), "new request should have no headers");
405 }
406
407 #[test]
408 fn response_header_mutation() {
409 let mut resp = Response {
410 status: StatusCode::OK,
411 headers: HeaderMap::new(),
412 };
413 resp.headers.insert("x-powered-by", "praxis".parse().unwrap());
414 assert_eq!(resp.headers["x-powered-by"], "praxis");
415 }
416
417 #[test]
418 fn response_status_codes() {
419 for code in [200_u16, 404, 500] {
420 let resp = Response {
421 status: StatusCode::from_u16(code).unwrap(),
422 headers: HeaderMap::new(),
423 };
424 assert_eq!(resp.status.as_u16(), code);
425 }
426 }
427
428 #[test]
429 fn cluster_name_returns_none_when_unset() {
430 let req = crate::test_utils::make_request(Method::GET, "/");
431 let ctx = crate::test_utils::make_filter_context(&req);
432 assert!(ctx.cluster_name().is_none(), "cluster name should be None when unset");
433 }
434
435 #[test]
436 fn cluster_name_returns_value_when_set() {
437 let req = crate::test_utils::make_request(Method::GET, "/");
438 let mut ctx = crate::test_utils::make_filter_context(&req);
439 ctx.cluster = Some(Arc::from("backend"));
440 assert_eq!(
441 ctx.cluster_name(),
442 Some("backend"),
443 "cluster name should return set value"
444 );
445 }
446
447 #[test]
448 fn upstream_addr_returns_none_when_unset() {
449 let req = crate::test_utils::make_request(Method::GET, "/");
450 let ctx = crate::test_utils::make_filter_context(&req);
451 assert!(ctx.upstream_addr().is_none(), "upstream addr should be None when unset");
452 }
453
454 #[test]
455 fn upstream_addr_returns_value_when_set() {
456 let req = crate::test_utils::make_request(Method::GET, "/");
457 let mut ctx = crate::test_utils::make_filter_context(&req);
458 ctx.upstream = Some(Upstream {
459 address: Arc::from("10.0.0.1:8080"),
460 tls: None,
461 connection: Arc::new(praxis_core::connectivity::ConnectionOptions::default()),
462 });
463 assert_eq!(
464 ctx.upstream_addr(),
465 Some("10.0.0.1:8080"),
466 "upstream addr should return set address"
467 );
468 }
469
470 #[test]
471 fn request_id_returns_none_when_absent() {
472 let req = crate::test_utils::make_request(Method::GET, "/");
473 let ctx = crate::test_utils::make_filter_context(&req);
474 assert!(
475 ctx.request_id().is_none(),
476 "request ID should be None when header absent"
477 );
478 }
479
480 #[test]
481 fn request_id_returns_value_when_present() {
482 let mut req = crate::test_utils::make_request(Method::GET, "/");
483 req.headers.insert("x-request-id", "abc-123".parse().unwrap());
484 let ctx = crate::test_utils::make_filter_context(&req);
485 assert_eq!(
486 ctx.request_id(),
487 Some("abc-123"),
488 "request ID should return header value"
489 );
490 }
491
492 #[test]
493 fn set_request_body_mode_upgrades_stream_to_stream_buffer() {
494 let req = crate::test_utils::make_request(Method::GET, "/");
495 let mut ctx = crate::test_utils::make_filter_context(&req);
496 assert_eq!(ctx.request_body_mode, BodyMode::Stream, "should start as Stream");
497 ctx.set_request_body_mode(BodyMode::StreamBuffer { max_bytes: Some(4096) });
498 assert_eq!(
499 ctx.request_body_mode,
500 BodyMode::StreamBuffer { max_bytes: Some(4096) },
501 "Stream should upgrade to StreamBuffer"
502 );
503 }
504
505 #[test]
506 fn set_request_body_mode_cannot_downgrade() {
507 let req = crate::test_utils::make_request(Method::GET, "/");
508 let mut ctx = crate::test_utils::make_filter_context(&req);
509 ctx.set_request_body_mode(BodyMode::StreamBuffer { max_bytes: Some(2048) });
510 ctx.set_request_body_mode(BodyMode::Stream);
511 assert_eq!(
512 ctx.request_body_mode,
513 BodyMode::StreamBuffer { max_bytes: Some(2048) },
514 "StreamBuffer should not downgrade to Stream"
515 );
516 }
517
518 #[test]
519 fn set_response_body_mode_upgrades_stream_to_stream_buffer() {
520 let req = crate::test_utils::make_request(Method::GET, "/");
521 let mut ctx = crate::test_utils::make_filter_context(&req);
522 assert_eq!(ctx.response_body_mode, BodyMode::Stream, "should start as Stream");
523 ctx.set_response_body_mode(BodyMode::StreamBuffer { max_bytes: Some(8192) });
524 assert_eq!(
525 ctx.response_body_mode,
526 BodyMode::StreamBuffer { max_bytes: Some(8192) },
527 "Stream should upgrade to StreamBuffer"
528 );
529 }
530
531 #[test]
532 fn set_request_body_mode_stream_buffer_then_stream_buffer_merges_limits() {
533 let req = crate::test_utils::make_request(Method::GET, "/");
534 let mut ctx = crate::test_utils::make_filter_context(&req);
535 ctx.set_request_body_mode(BodyMode::StreamBuffer { max_bytes: Some(2048) });
536 ctx.set_request_body_mode(BodyMode::StreamBuffer { max_bytes: Some(1024) });
537 assert_eq!(
538 ctx.request_body_mode,
539 BodyMode::StreamBuffer { max_bytes: Some(2048) },
540 "larger StreamBuffer limit should win when merging"
541 );
542 }
543
544 #[test]
545 fn get_metadata_returns_none_when_empty() {
546 let req = crate::test_utils::make_request(Method::GET, "/");
547 let ctx = crate::test_utils::make_filter_context(&req);
548 assert!(
549 ctx.get_metadata("json_rpc.method").is_none(),
550 "get_metadata should return None for absent key"
551 );
552 }
553
554 #[test]
555 fn set_metadata_then_get_returns_value() {
556 let req = crate::test_utils::make_request(Method::GET, "/");
557 let mut ctx = crate::test_utils::make_filter_context(&req);
558 ctx.set_metadata("json_rpc.method", "service/invoke");
559 assert_eq!(
560 ctx.get_metadata("json_rpc.method"),
561 Some("service/invoke"),
562 "get_metadata should return the set value"
563 );
564 }
565
566 #[test]
567 fn set_metadata_overwrites_existing() {
568 let req = crate::test_utils::make_request(Method::GET, "/");
569 let mut ctx = crate::test_utils::make_filter_context(&req);
570 ctx.set_metadata("classifier.label", "ProcessRequest");
571 ctx.set_metadata("classifier.label", "GetTask");
572 assert_eq!(
573 ctx.get_metadata("classifier.label"),
574 Some("GetTask"),
575 "set_metadata should overwrite previous value"
576 );
577 }
578
579 #[test]
580 fn metadata_independent_of_filter_results() {
581 let req = crate::test_utils::make_request(Method::GET, "/");
582 let mut ctx = crate::test_utils::make_filter_context(&req);
583 ctx.set_metadata("request.session_id", "gw-123");
584 ctx.filter_results.clear();
585 assert_eq!(
586 ctx.get_metadata("request.session_id"),
587 Some("gw-123"),
588 "clearing filter_results should not affect metadata"
589 );
590 }
591
592 #[test]
593 fn set_metadata_accepts_owned_strings() {
594 let req = crate::test_utils::make_request(Method::GET, "/");
595 let mut ctx = crate::test_utils::make_filter_context(&req);
596 let key = "request.task_id".to_owned();
597 let value = "task-456".to_owned();
598 ctx.set_metadata(key, value);
599 assert_eq!(
600 ctx.get_metadata("request.task_id"),
601 Some("task-456"),
602 "set_metadata should accept owned Strings"
603 );
604 }
605
606 #[test]
607 fn kv_stores_returns_none_when_unset() {
608 let req = crate::test_utils::make_request(Method::GET, "/");
609 let ctx = crate::test_utils::make_filter_context(&req);
610 assert!(ctx.kv_stores.is_none(), "kv_stores should be None when unset");
611 }
612
613 #[test]
614 fn kv_stores_returns_registry_when_set() {
615 let registry = KvStoreRegistry::new();
616 let store = registry.get_or_create("routing");
617 store.set("model", Arc::from("model-gamma-1"));
618
619 let req = crate::test_utils::make_request(Method::GET, "/");
620 let mut ctx = crate::test_utils::make_filter_context(&req);
621 ctx.kv_stores = Some(®istry);
622
623 let store = ctx.kv_stores.unwrap().get("routing").unwrap();
624 assert_eq!(
625 store.get("model").as_deref(),
626 Some("model-gamma-1"),
627 "filter should read KV store via context"
628 );
629 }
630
631 #[test]
632 fn kv_stores_write_from_context_is_visible() {
633 let registry = KvStoreRegistry::new();
634 let store = registry.get_or_create("flags");
635
636 let req = crate::test_utils::make_request(Method::GET, "/");
637 let mut ctx = crate::test_utils::make_filter_context(&req);
638 ctx.kv_stores = Some(®istry);
639
640 ctx.kv_stores
641 .unwrap()
642 .get("flags")
643 .unwrap()
644 .set("dark_mode", Arc::from("true"));
645 assert_eq!(
646 store.get("dark_mode").as_deref(),
647 Some("true"),
648 "write through context should be visible on the original store"
649 );
650 }
651
652 #[test]
653 fn kv_stores_missing_store_returns_none() {
654 let registry = KvStoreRegistry::new();
655
656 let req = crate::test_utils::make_request(Method::GET, "/");
657 let mut ctx = crate::test_utils::make_filter_context(&req);
658 ctx.kv_stores = Some(®istry);
659
660 assert!(
661 ctx.kv_stores.unwrap().get("nonexistent").is_none(),
662 "missing store name should return None"
663 );
664 }
665
666 #[test]
667 fn set_metadata_rejects_empty_key() {
668 let req = crate::test_utils::make_request(Method::GET, "/");
669 let mut ctx = crate::test_utils::make_filter_context(&req);
670 ctx.set_metadata("", "val");
671 assert!(ctx.get_metadata("").is_none(), "empty key should be silently rejected");
672 }
673
674 #[test]
675 fn set_metadata_rejects_long_key() {
676 let req = crate::test_utils::make_request(Method::GET, "/");
677 let mut ctx = crate::test_utils::make_filter_context(&req);
678 let long_key = "k".repeat(65);
679 ctx.set_metadata(long_key.as_str(), "val");
680 assert!(
681 ctx.get_metadata(long_key.as_str()).is_none(),
682 "65-byte key should be rejected"
683 );
684 }
685
686 #[test]
687 fn set_metadata_accepts_max_length_key() {
688 let req = crate::test_utils::make_request(Method::GET, "/");
689 let mut ctx = crate::test_utils::make_filter_context(&req);
690 let max_key = "k".repeat(64);
691 ctx.set_metadata(max_key.as_str(), "val");
692 assert_eq!(
693 ctx.get_metadata(max_key.as_str()),
694 Some("val"),
695 "64-byte key should be accepted"
696 );
697 }
698
699 #[test]
700 fn set_metadata_rejects_long_value() {
701 let req = crate::test_utils::make_request(Method::GET, "/");
702 let mut ctx = crate::test_utils::make_filter_context(&req);
703 let long_value = "v".repeat(257);
704 ctx.set_metadata("key", long_value.as_str());
705 assert!(ctx.get_metadata("key").is_none(), "257-byte value should be rejected");
706 }
707
708 #[test]
709 fn set_metadata_rejects_when_entry_limit_reached() {
710 let req = crate::test_utils::make_request(Method::GET, "/");
711 let mut ctx = crate::test_utils::make_filter_context(&req);
712 for i in 0..MAX_METADATA_ENTRIES {
713 ctx.set_metadata(format!("key.{i}"), "value");
714 }
715 assert_eq!(
716 ctx.filter_metadata.len(),
717 MAX_METADATA_ENTRIES,
718 "should accept exactly {MAX_METADATA_ENTRIES} entries"
719 );
720
721 ctx.set_metadata("overflow", "value");
722 assert!(
723 ctx.get_metadata("overflow").is_none(),
724 "entry beyond limit should be rejected"
725 );
726 }
727
728 #[test]
729 fn set_metadata_allows_overwrite_at_limit() {
730 let req = crate::test_utils::make_request(Method::GET, "/");
731 let mut ctx = crate::test_utils::make_filter_context(&req);
732 for i in 0..MAX_METADATA_ENTRIES {
733 ctx.set_metadata(format!("key.{i}"), "old");
734 }
735
736 ctx.set_metadata("key.0", "new");
737 assert_eq!(
738 ctx.get_metadata("key.0"),
739 Some("new"),
740 "overwriting existing key at limit should succeed"
741 );
742 assert_eq!(
743 ctx.filter_metadata.len(),
744 MAX_METADATA_ENTRIES,
745 "overwrite should not increase entry count"
746 );
747 }
748
749 #[test]
750 fn kv_stores_lookup_with_match_types() {
751 use praxis_core::kv::MatchType;
752
753 let registry = KvStoreRegistry::new();
754 let store = registry.get_or_create("routes");
755 store.set("route.api.v1", Arc::from("api_cluster"));
756 store.set("route.web.main", Arc::from("web_cluster"));
757
758 let req = crate::test_utils::make_request(Method::GET, "/");
759 let mut ctx = crate::test_utils::make_filter_context(&req);
760 ctx.kv_stores = Some(®istry);
761
762 let store = ctx.kv_stores.unwrap().get("routes").unwrap();
763 assert!(
764 store.lookup("route.api", MatchType::Prefix).unwrap().is_some(),
765 "prefix lookup should match route.api.v1"
766 );
767 assert!(
768 store.lookup(".main", MatchType::Suffix).unwrap().is_some(),
769 "suffix lookup should match route.web.main"
770 );
771 }
772
773 #[test]
778 fn insert_and_get_filter_state_returns_typed_value() {
779 let req = crate::test_utils::make_request(Method::GET, "/");
780 let mut ctx = crate::test_utils::make_filter_context(&req);
781 ctx.current_filter_id = Some(0);
782 ctx.insert_filter_state(42_u64);
783 assert_eq!(
784 ctx.get_filter_state::<u64>(),
785 Some(&42_u64),
786 "should return the inserted value"
787 );
788 }
789
790 #[test]
791 fn get_filter_state_returns_none_when_empty() {
792 let req = crate::test_utils::make_request(Method::GET, "/");
793 let mut ctx = crate::test_utils::make_filter_context(&req);
794 ctx.current_filter_id = Some(0);
795 assert!(
796 ctx.get_filter_state::<u64>().is_none(),
797 "should return None when no state stored"
798 );
799 }
800
801 #[test]
802 fn get_filter_state_returns_none_for_wrong_type() {
803 let req = crate::test_utils::make_request(Method::GET, "/");
804 let mut ctx = crate::test_utils::make_filter_context(&req);
805 ctx.current_filter_id = Some(0);
806 ctx.insert_filter_state(42_u64);
807 assert!(
808 ctx.get_filter_state::<String>().is_none(),
809 "should return None for type mismatch"
810 );
811 }
812
813 #[test]
814 fn get_filter_state_returns_none_when_no_index() {
815 let req = crate::test_utils::make_request(Method::GET, "/");
816 let mut ctx = crate::test_utils::make_filter_context(&req);
817 ctx.filter_state.insert(0, Box::new(42_u64));
818 assert!(
819 ctx.get_filter_state::<u64>().is_none(),
820 "should return None when current_filter_id is None"
821 );
822 }
823
824 #[test]
825 fn get_filter_state_mut_allows_mutation() {
826 let req = crate::test_utils::make_request(Method::GET, "/");
827 let mut ctx = crate::test_utils::make_filter_context(&req);
828 ctx.current_filter_id = Some(0);
829 ctx.insert_filter_state(10_u64);
830 *ctx.get_filter_state_mut::<u64>().unwrap() += 5;
831 assert_eq!(
832 ctx.get_filter_state::<u64>(),
833 Some(&15_u64),
834 "mutation through get_mut should be visible"
835 );
836 }
837
838 #[test]
839 fn remove_filter_state_takes_ownership() {
840 let req = crate::test_utils::make_request(Method::GET, "/");
841 let mut ctx = crate::test_utils::make_filter_context(&req);
842 ctx.current_filter_id = Some(0);
843 ctx.insert_filter_state("hello".to_owned());
844 let removed = ctx.remove_filter_state::<String>();
845 assert_eq!(removed.as_deref(), Some("hello"), "should return the stored value");
846 assert!(
847 ctx.get_filter_state::<String>().is_none(),
848 "state should be gone after remove"
849 );
850 }
851
852 #[test]
853 fn remove_filter_state_returns_none_for_wrong_type() {
854 let req = crate::test_utils::make_request(Method::GET, "/");
855 let mut ctx = crate::test_utils::make_filter_context(&req);
856 ctx.current_filter_id = Some(0);
857 ctx.insert_filter_state(42_u64);
858 assert!(
859 ctx.remove_filter_state::<String>().is_none(),
860 "type mismatch should return None"
861 );
862 assert!(
863 ctx.get_filter_state::<u64>().is_some(),
864 "type mismatch remove should not destroy the entry"
865 );
866 }
867
868 #[test]
869 fn different_indices_do_not_collide() {
870 let req = crate::test_utils::make_request(Method::GET, "/");
871 let mut ctx = crate::test_utils::make_filter_context(&req);
872 ctx.current_filter_id = Some(0);
873 ctx.insert_filter_state(100_u64);
874 ctx.current_filter_id = Some(1);
875 ctx.insert_filter_state(200_u64);
876
877 ctx.current_filter_id = Some(0);
878 assert_eq!(ctx.get_filter_state::<u64>(), Some(&100_u64), "index 0 state");
879
880 ctx.current_filter_id = Some(1);
881 assert_eq!(ctx.get_filter_state::<u64>(), Some(&200_u64), "index 1 state");
882 }
883
884 #[test]
885 fn insert_filter_state_is_noop_without_index() {
886 let req = crate::test_utils::make_request(Method::GET, "/");
887 let mut ctx = crate::test_utils::make_filter_context(&req);
888 ctx.insert_filter_state(42_u64);
889 assert!(ctx.filter_state.is_empty(), "state map should remain empty");
890 }
891}