1use std::{collections::VecDeque, net::IpAddr, sync::Arc, time::Instant};
7
8use bytes::Bytes;
9use praxis_core::connectivity::Upstream;
10use praxis_filter::{BodyBuffer, BodyMode, FilterPipeline, Request};
11use tokio::sync::OwnedSemaphorePermit;
12
13#[expect(clippy::struct_excessive_bools, reason = "lifecycle flags")]
29pub struct PingoraRequestCtx {
30 pub _connection_permit: Option<OwnedSemaphorePermit>,
36
37 pub _global_connection_permit: Option<OwnedSemaphorePermit>,
41
42 pub client_addr: Option<IpAddr>,
44
45 pub client_http_version: Option<http::Version>,
50
51 pub cluster: Option<Arc<str>>,
53
54 pub cached_body_done_indices: Vec<bool>,
60
61 pub cached_executed_filter_indices: Vec<bool>,
66
67 pub downstream_tls: bool,
74
75 pub connection_upgraded: bool,
81
82 pub extensions: praxis_filter::RequestExtensions,
89
90 pub filter_metadata: std::collections::HashMap<String, String>,
96
97 pub mutated_request_body_len: Option<usize>,
103
104 pub pinned_pipeline: Option<Arc<FilterPipeline>>,
114
115 pub filter_results: std::collections::HashMap<&'static str, praxis_filter::FilterResultSet>,
121
122 pub filter_state: std::collections::HashMap<usize, Box<dyn std::any::Any + Send + Sync>>,
132
133 pub metrics_cluster: Option<Arc<str>>,
137
138 pub metrics_cluster_shared: Option<::metrics::SharedString>,
145
146 pub pre_read_body: Option<VecDeque<Bytes>>,
154
155 pub request_body_buffer: Option<BodyBuffer>,
159
160 pub request_body_bytes: u64,
162
163 pub request_body_mode: BodyMode,
167
168 pub request_body_released: bool,
171
172 pub request_is_idempotent: bool,
174
175 pub request_snapshot: Option<Request>,
177
178 pub request_start: Instant,
180
181 pub response_body_buffer: Option<BodyBuffer>,
185
186 pub response_body_bytes: u64,
188
189 pub response_body_mode: BodyMode,
193
194 pub response_body_released: bool,
196
197 pub upstream_response_status: Option<u16>,
200
201 pub response_phase_done: bool,
205
206 pub retries: u32,
208
209 pub selected_endpoint_index: Option<usize>,
213
214 pub rewritten_path: Option<String>,
221
222 pub upstream: Option<Upstream>,
224
225 pub upstream_for_retry: Option<Upstream>,
227}
228
229macro_rules! filter_context {
239 ($ctx:expr, $pipeline:expr, $request:expr, $response_header:expr) => {{
240 $pipeline.prepare_extensions(&mut $ctx.extensions);
241 praxis_filter::HttpFilterContext {
242 body_done_indices: std::mem::take(&mut $ctx.cached_body_done_indices),
243 branch_iterations: std::collections::HashMap::new(),
244 client_addr: $ctx.client_addr,
245 cluster: $ctx.cluster.take(),
246 current_filter_id: None,
247 downstream_tls: $ctx.downstream_tls,
248 extensions: std::mem::take(&mut $ctx.extensions),
249 executed_filter_indices: std::mem::take(&mut $ctx.cached_executed_filter_indices),
250 extra_request_headers: Vec::new(),
251 request_headers_to_remove: Vec::new(),
252 request_headers_to_set: Vec::new(),
253 filter_metadata: std::mem::take(&mut $ctx.filter_metadata),
254 filter_results: std::mem::take(&mut $ctx.filter_results),
255 filter_state: std::mem::take(&mut $ctx.filter_state),
256 health_registry: $pipeline.health_registry(),
257 id_generator: $pipeline.id_generator(),
258 kv_stores: $pipeline.kv_stores(),
259 request: $request,
260 request_body_bytes: $ctx.request_body_bytes,
261 request_body_mode: $ctx.request_body_mode,
262 request_start: $ctx.request_start,
263 response_body_bytes: $ctx.response_body_bytes,
264 response_body_mode: $ctx.response_body_mode,
265 response_header: $response_header,
266 response_headers_modified: false,
267 rewritten_path: $ctx.rewritten_path.take(),
268 selected_endpoint_index: $ctx.selected_endpoint_index,
269 time_source: $pipeline.time_source(),
270 upstream: $ctx.upstream.take(),
271 }
272 }};
273}
274
275impl PingoraRequestCtx {
276 pub fn build_filter_context<'a>(
300 &mut self,
301 pipeline: &'a FilterPipeline,
302 request: &'a Request,
303 response_header: Option<&'a mut praxis_filter::Response>,
304 ) -> praxis_filter::HttpFilterContext<'a> {
305 filter_context!(self, pipeline, request, response_header)
306 }
307
308 pub fn filter_context_for<'a>(
335 &'a mut self,
336 pipeline: &'a FilterPipeline,
337 response_header: Option<&'a mut praxis_filter::Response>,
338 ) -> Option<praxis_filter::HttpFilterContext<'a>> {
339 let request = self.request_snapshot.as_ref()?;
340 Some(filter_context!(self, pipeline, request, response_header))
341 }
342
343 pub fn pin_pipeline(&mut self, swap: &arc_swap::ArcSwap<FilterPipeline>) -> Arc<FilterPipeline> {
356 if let Some(existing) = &self.pinned_pipeline {
357 return Arc::clone(existing);
358 }
359 let pipeline = swap.load_full();
360 self.pinned_pipeline = Some(Arc::clone(&pipeline));
361 pipeline
362 }
363
364 pub fn pipeline(&self, swap: &arc_swap::ArcSwap<FilterPipeline>) -> Arc<FilterPipeline> {
377 self.pinned_pipeline
378 .as_ref()
379 .map_or_else(|| swap.load_full(), Arc::clone)
380 }
381}
382
383impl Default for PingoraRequestCtx {
384 #[expect(
385 clippy::too_many_lines,
386 reason = "context default enumerates all lifecycle fields explicitly"
387 )]
388 fn default() -> Self {
389 Self {
390 _connection_permit: None,
391 _global_connection_permit: None,
392 cached_body_done_indices: Vec::new(),
393 cached_executed_filter_indices: Vec::new(),
394 client_addr: None,
395 client_http_version: None,
396 cluster: None,
397 connection_upgraded: false,
398 downstream_tls: false,
399 extensions: praxis_filter::RequestExtensions::new(),
400 filter_metadata: std::collections::HashMap::new(),
401 mutated_request_body_len: None,
402 pinned_pipeline: None,
403 filter_results: std::collections::HashMap::new(),
404 filter_state: std::collections::HashMap::new(),
405 metrics_cluster: None,
406 metrics_cluster_shared: None,
407 pre_read_body: None,
408 request_body_buffer: None,
409 request_body_bytes: 0,
410 request_body_mode: BodyMode::Stream,
411 request_body_released: false,
412 request_is_idempotent: false,
413 request_snapshot: None,
414 request_start: Instant::now(),
415 response_body_buffer: None,
416 response_body_bytes: 0,
417 response_body_mode: BodyMode::Stream,
418 response_body_released: false,
419 upstream_response_status: None,
420 response_phase_done: false,
421 retries: 0,
422 rewritten_path: None,
423 selected_endpoint_index: None,
424 upstream: None,
425 upstream_for_retry: None,
426 }
427 }
428}
429
430#[cfg(test)]
435#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
436#[allow(
437 clippy::unwrap_used,
438 clippy::expect_used,
439 clippy::indexing_slicing,
440 clippy::significant_drop_tightening,
441 clippy::too_many_lines,
442 reason = "tests"
443)]
444mod tests {
445 use std::{
446 collections::VecDeque,
447 net::{IpAddr, Ipv4Addr},
448 sync::Arc,
449 };
450
451 use bytes::Bytes;
452 use http::{HeaderMap, Method, Uri};
453 use praxis_core::connectivity::Upstream;
454 use praxis_filter::{BodyBuffer, BodyMode, FilterPipeline, FilterRegistry};
455
456 use super::*;
457
458 #[test]
459 fn default_state_has_no_client_addr() {
460 let ctx = default_ctx();
461 assert!(ctx.client_addr.is_none(), "default client_addr should be None");
462 }
463
464 #[test]
465 fn default_state_has_no_cluster() {
466 let ctx = default_ctx();
467 assert!(ctx.cluster.is_none(), "default cluster should be None");
468 }
469
470 #[test]
471 fn default_state_has_zero_retries() {
472 let ctx = default_ctx();
473 assert_eq!(ctx.retries, 0, "default retries should be zero");
474 }
475
476 #[test]
477 fn default_state_flags_are_false() {
478 let ctx = default_ctx();
479 assert!(
480 !ctx.request_body_released,
481 "default request_body_released should be false"
482 );
483 assert!(
484 !ctx.response_body_released,
485 "default response_body_released should be false"
486 );
487 assert!(
488 !ctx.request_is_idempotent,
489 "default request_is_idempotent should be false"
490 );
491 assert!(!ctx.response_phase_done, "default response_phase_done should be false");
492 }
493
494 #[test]
495 fn default_state_buffers_are_none() {
496 let ctx = default_ctx();
497 assert!(
498 ctx.request_body_buffer.is_none(),
499 "default request_body_buffer should be None"
500 );
501 assert!(
502 ctx.response_body_buffer.is_none(),
503 "default response_body_buffer should be None"
504 );
505 assert!(ctx.pre_read_body.is_none(), "default pre_read_body should be None");
506 }
507
508 #[test]
509 fn default_state_snapshots_are_none() {
510 let ctx = default_ctx();
511 assert!(
512 ctx.request_snapshot.is_none(),
513 "default request_snapshot should be None"
514 );
515 assert!(ctx.upstream.is_none(), "default upstream should be None");
516 assert!(
517 ctx.upstream_for_retry.is_none(),
518 "default upstream_for_retry should be None"
519 );
520 }
521
522 #[test]
523 fn set_client_addr() {
524 let mut ctx = default_ctx();
525 let addr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100));
526 ctx.client_addr = Some(addr);
527 assert_eq!(
528 ctx.client_addr.unwrap(),
529 addr,
530 "client_addr should match assigned value"
531 );
532 }
533
534 #[test]
535 fn set_cluster() {
536 let mut ctx = default_ctx();
537 ctx.cluster = Some(Arc::from("api-cluster"));
538 assert_eq!(
539 ctx.cluster.as_deref(),
540 Some("api-cluster"),
541 "cluster should match assigned value"
542 );
543 }
544
545 #[test]
546 fn set_upstream() {
547 let mut ctx = default_ctx();
548 let upstream = Upstream {
549 address: Arc::from("10.0.0.1:80"),
550 tls: None,
551 connection: Arc::new(praxis_core::connectivity::ConnectionOptions::default()),
552 };
553 ctx.upstream = Some(upstream.clone());
554 assert_eq!(
555 &*ctx.upstream.as_ref().unwrap().address,
556 "10.0.0.1:80",
557 "upstream address should match assigned value"
558 );
559 }
560
561 #[test]
562 fn increment_retries() {
563 let mut ctx = default_ctx();
564 ctx.retries += 1;
565 ctx.retries += 1;
566 assert_eq!(ctx.retries, 2, "retries should be 2 after two increments");
567 }
568
569 #[test]
570 fn release_request_body_flag() {
571 let mut ctx = default_ctx();
572 assert!(!ctx.request_body_released, "request_body_released should start false");
573 ctx.request_body_released = true;
574 assert!(
575 ctx.request_body_released,
576 "request_body_released should be true after setting"
577 );
578 }
579
580 #[test]
581 fn release_response_body_flag() {
582 let mut ctx = default_ctx();
583 assert!(!ctx.response_body_released, "response_body_released should start false");
584 ctx.response_body_released = true;
585 assert!(
586 ctx.response_body_released,
587 "response_body_released should be true after setting"
588 );
589 }
590
591 #[test]
592 fn response_phase_done_flag() {
593 let mut ctx = default_ctx();
594 assert!(!ctx.response_phase_done, "response_phase_done should start false");
595 ctx.response_phase_done = true;
596 assert!(
597 ctx.response_phase_done,
598 "response_phase_done should be true after setting"
599 );
600 }
601
602 #[test]
603 fn set_pre_read_body() {
604 let mut ctx = default_ctx();
605 let chunks = VecDeque::from([Bytes::from_static(b"chunk1"), Bytes::from_static(b"chunk2")]);
606 ctx.pre_read_body = Some(chunks);
607 let body = ctx.pre_read_body.as_ref().unwrap();
608 assert_eq!(body.len(), 2, "pre_read_body should contain 2 chunks");
609 assert_eq!(body[0], Bytes::from_static(b"chunk1"), "first chunk should be 'chunk1'");
610 assert_eq!(
611 body[1],
612 Bytes::from_static(b"chunk2"),
613 "second chunk should be 'chunk2'"
614 );
615 }
616
617 #[test]
618 fn set_request_snapshot() {
619 let mut ctx = default_ctx();
620 let snapshot = Request {
621 method: Method::POST,
622 uri: "/api/data".parse::<Uri>().unwrap(),
623 headers: HeaderMap::new(),
624 };
625 ctx.request_snapshot = Some(snapshot);
626 let snap = ctx.request_snapshot.as_ref().unwrap();
627 assert_eq!(snap.method, Method::POST, "snapshot method should be POST");
628 assert_eq!(snap.uri.path(), "/api/data", "snapshot URI path should be /api/data");
629 }
630
631 #[test]
632 fn request_body_buffer_lifecycle() {
633 let mut ctx = default_ctx();
634 let mut buf = BodyBuffer::new(100);
635 buf.push(Bytes::from_static(b"data")).unwrap();
636 ctx.request_body_buffer = Some(buf);
637
638 assert!(
639 ctx.request_body_buffer.is_some(),
640 "buffer should be present after assignment"
641 );
642 let taken = ctx.request_body_buffer.take().unwrap();
643 assert_eq!(
644 taken.freeze(),
645 Bytes::from_static(b"data"),
646 "frozen buffer should contain pushed data"
647 );
648 assert!(ctx.request_body_buffer.is_none(), "buffer should be None after take");
649 }
650
651 #[test]
652 fn default_request_body_mode_is_stream() {
653 let ctx = default_ctx();
654 assert_eq!(
655 ctx.request_body_mode,
656 BodyMode::Stream,
657 "default request_body_mode should be Stream"
658 );
659 }
660
661 #[test]
662 fn default_response_body_mode_is_stream() {
663 let ctx = default_ctx();
664 assert_eq!(
665 ctx.response_body_mode,
666 BodyMode::Stream,
667 "default response_body_mode should be Stream"
668 );
669 }
670
671 #[test]
672 fn set_request_body_mode() {
673 let mut ctx = default_ctx();
674 ctx.request_body_mode = BodyMode::StreamBuffer { max_bytes: Some(4096) };
675 assert_eq!(
676 ctx.request_body_mode,
677 BodyMode::StreamBuffer { max_bytes: Some(4096) },
678 "request_body_mode should match assigned value"
679 );
680 }
681
682 #[test]
683 fn set_response_body_mode() {
684 let mut ctx = default_ctx();
685 ctx.response_body_mode = BodyMode::StreamBuffer { max_bytes: Some(8192) };
686 assert_eq!(
687 ctx.response_body_mode,
688 BodyMode::StreamBuffer { max_bytes: Some(8192) },
689 "response_body_mode should match assigned value"
690 );
691 }
692
693 #[test]
698 fn metadata_roundtrip_through_filter_context() {
699 let registry = FilterRegistry::with_builtins();
700 let pipeline = FilterPipeline::build(&mut [], ®istry).unwrap();
701 let request = Request {
702 method: Method::GET,
703 uri: "/".parse::<Uri>().unwrap(),
704 headers: HeaderMap::new(),
705 };
706
707 let mut ctx = default_ctx();
708 ctx.filter_metadata.insert("rpc.method".to_owned(), "echo".to_owned());
709 ctx.filter_metadata.insert("rpc.status".to_owned(), "ok".to_owned());
710
711 let fctx = ctx.build_filter_context(&pipeline, &request, None);
712 assert_eq!(
713 fctx.get_metadata("rpc.method"),
714 Some("echo"),
715 "metadata written before build should survive into filter context"
716 );
717 assert_eq!(
718 fctx.get_metadata("rpc.status"),
719 Some("ok"),
720 "multiple metadata keys should round-trip"
721 );
722 }
723
724 #[test]
725 fn metadata_written_in_filter_context_persists_back() {
726 let registry = FilterRegistry::with_builtins();
727 let pipeline = FilterPipeline::build(&mut [], ®istry).unwrap();
728 let request = Request {
729 method: Method::GET,
730 uri: "/".parse::<Uri>().unwrap(),
731 headers: HeaderMap::new(),
732 };
733
734 let mut ctx = default_ctx();
735 let mut fctx = ctx.build_filter_context(&pipeline, &request, None);
736 fctx.set_metadata("trace.id", "abc-123");
737 fctx.set_metadata("trace.span", "42");
738
739 ctx.filter_metadata = fctx.filter_metadata;
740 assert_eq!(
741 ctx.filter_metadata.get("trace.id").map(String::as_str),
742 Some("abc-123"),
743 "metadata set in filter context should persist back to protocol context"
744 );
745 assert_eq!(
746 ctx.filter_metadata.get("trace.span").map(String::as_str),
747 Some("42"),
748 "multiple metadata keys should persist back"
749 );
750 }
751
752 #[test]
757 fn pin_pipeline_captures_current_arc() {
758 let registry = FilterRegistry::with_builtins();
759 let pipeline_a = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
760 let swap = arc_swap::ArcSwap::from(Arc::clone(&pipeline_a));
761
762 let mut ctx = default_ctx();
763 let pinned = ctx.pin_pipeline(&swap);
764 assert!(
765 Arc::ptr_eq(&pinned, &pipeline_a),
766 "pin_pipeline should return the current pipeline"
767 );
768 assert!(
769 Arc::ptr_eq(ctx.pinned_pipeline.as_ref().unwrap(), &pipeline_a),
770 "pinned_pipeline should be stored in ctx"
771 );
772 }
773
774 #[test]
775 fn pipeline_returns_pinned_after_reload() {
776 let registry = FilterRegistry::with_builtins();
777 let pipeline_a = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
778 let swap = arc_swap::ArcSwap::from(Arc::clone(&pipeline_a));
779
780 let mut ctx = default_ctx();
781 ctx.pin_pipeline(&swap);
782
783 let pipeline_b = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
784 swap.store(pipeline_b);
785
786 let later = ctx.pipeline(&swap);
787 assert!(
788 Arc::ptr_eq(&later, &pipeline_a),
789 "later hooks should still return pipeline A after reload"
790 );
791 }
792
793 #[test]
794 fn new_request_after_reload_pins_new_pipeline() {
795 let registry = FilterRegistry::with_builtins();
796 let pipeline_a = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
797 let swap = arc_swap::ArcSwap::from(Arc::clone(&pipeline_a));
798
799 let mut ctx_a = default_ctx();
800 ctx_a.pin_pipeline(&swap);
801
802 let pipeline_b = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
803 swap.store(Arc::clone(&pipeline_b));
804
805 let mut ctx_b = default_ctx();
806 ctx_b.pin_pipeline(&swap);
807
808 assert!(
809 Arc::ptr_eq(&ctx_a.pipeline(&swap), &pipeline_a),
810 "request A should use old pipeline"
811 );
812 assert!(
813 Arc::ptr_eq(&ctx_b.pipeline(&swap), &pipeline_b),
814 "request B should use new pipeline"
815 );
816 }
817
818 #[test]
819 fn old_pipeline_drops_after_ctx_drops() {
820 let registry = FilterRegistry::with_builtins();
821 let pipeline_a = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
822 let weak_a = Arc::downgrade(&pipeline_a);
823 let swap = arc_swap::ArcSwap::from(pipeline_a);
824
825 let mut ctx = default_ctx();
826 ctx.pin_pipeline(&swap);
827
828 swap.store(Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap()));
829
830 assert!(weak_a.upgrade().is_some(), "old pipeline alive while ctx exists");
831
832 drop(ctx);
833
834 assert!(weak_a.upgrade().is_none(), "old pipeline drops with ctx");
835 }
836
837 #[test]
838 fn pipeline_helper_returns_pinned_for_every_phase() {
839 let registry = FilterRegistry::with_builtins();
840 let pipeline_a = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
841 let swap = arc_swap::ArcSwap::from(Arc::clone(&pipeline_a));
842
843 let mut ctx = default_ctx();
844 ctx.pin_pipeline(&swap);
845
846 swap.store(Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap()));
847
848 for phase in ["request_body", "response", "response_body", "logging"] {
849 let p = ctx.pipeline(&swap);
850 assert!(
851 Arc::ptr_eq(&p, &pipeline_a),
852 "{phase}: should still return pinned pipeline A"
853 );
854 }
855 }
856
857 #[test]
858 fn pipeline_fallback_when_not_pinned() {
859 let registry = FilterRegistry::with_builtins();
860 let pipeline_a = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
861 let swap = arc_swap::ArcSwap::from(Arc::clone(&pipeline_a));
862
863 let ctx = default_ctx();
864
865 let loaded = ctx.pipeline(&swap);
866 assert!(
867 Arc::ptr_eq(&loaded, &pipeline_a),
868 "unpinned ctx should fall back to current ArcSwap value"
869 );
870 }
871
872 #[test]
873 fn pin_pipeline_is_idempotent_after_reload() {
874 let registry = FilterRegistry::with_builtins();
875 let pipeline_a = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
876 let swap = arc_swap::ArcSwap::from(Arc::clone(&pipeline_a));
877
878 let mut ctx = default_ctx();
879 ctx.pin_pipeline(&swap);
880
881 let pipeline_b = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
882 swap.store(pipeline_b);
883
884 let second_pin = ctx.pin_pipeline(&swap);
885 assert!(
886 Arc::ptr_eq(&second_pin, &pipeline_a),
887 "repeated pin_pipeline after reload should return the original pin"
888 );
889 }
890
891 #[test]
892 fn filter_state_isolated_across_pipelines_with_same_ids() {
893 let registry = FilterRegistry::with_builtins();
894 let pipeline_a = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
895 let pipeline_b = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
896
897 let request = Request {
898 method: Method::GET,
899 uri: "/".parse::<Uri>().unwrap(),
900 headers: HeaderMap::new(),
901 };
902
903 let mut ctx_a = default_ctx();
904 ctx_a.pinned_pipeline = Some(Arc::clone(&pipeline_a));
905 ctx_a.request_snapshot = Some(request.clone());
906 let mut fctx_a = ctx_a.build_filter_context(&pipeline_a, &request, None);
907 fctx_a.current_filter_id = Some(0);
908 fctx_a.insert_filter_state(String::from("from_pipeline_a"));
909 ctx_a.filter_state = fctx_a.filter_state;
910
911 let mut ctx_b = default_ctx();
912 ctx_b.pinned_pipeline = Some(Arc::clone(&pipeline_b));
913 ctx_b.request_snapshot = Some(request.clone());
914 let fctx_b = ctx_b.build_filter_context(&pipeline_b, &request, None);
915
916 assert!(
917 fctx_b.filter_state.is_empty(),
918 "request B should have its own empty state map"
919 );
920
921 let fctx_a2 = ctx_a.filter_context_for(&pipeline_a, None).unwrap();
922 assert_eq!(
923 fctx_a2.filter_state.get(&0).and_then(|v| v.downcast_ref::<String>()),
924 Some(&String::from("from_pipeline_a")),
925 "request A should still see its own state in a later phase"
926 );
927 }
928
929 fn default_ctx() -> PingoraRequestCtx {
935 PingoraRequestCtx::default()
936 }
937}