Skip to main content

praxis_filter/
context.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Transport-agnostic HTTP request/response metadata and per-request filter context.
5
6use 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
15// -----------------------------------------------------------------------------
16// Constants
17// -----------------------------------------------------------------------------
18
19/// Maximum entries allowed in the general `filter_metadata` map.
20///
21/// Individual keys and values are already size-bounded (64 / 256
22/// bytes), but without an entry count cap a filter chain could
23/// insert thousands of unique keys per request.
24const MAX_METADATA_ENTRIES: usize = 128;
25
26// -----------------------------------------------------------------------------
27// HttpFilterContext
28// -----------------------------------------------------------------------------
29
30/// Per-request mutable state shared across all HTTP filters.
31///
32/// Created by the protocol layer for each incoming request. Filters read
33/// and mutate it to select clusters, choose upstreams, and inject headers.
34pub struct HttpFilterContext<'a> {
35    /// Per-filter body-done tracking. When `true` at index `i`,
36    /// filter `i` is skipped for remaining body chunks.
37    pub body_done_indices: Vec<bool>,
38
39    /// Iteration counters for re-entrant branches.
40    /// Branch name -> current iteration count.
41    pub branch_iterations: HashMap<Arc<str>, u32>,
42
43    /// Downstream client IP address (from the TCP connection).
44    pub client_addr: Option<IpAddr>,
45
46    /// The cluster name selected by the router filter.
47    pub cluster: Option<Arc<str>>,
48
49    /// Stable invocation ID of the filter currently executing.
50    ///
51    /// Assigned at pipeline build time and unique within the
52    /// request's pinned [`FilterPipeline`]. Set by the pipeline
53    /// executor before each filter hook call and cleared after.
54    /// Filter state accessors use this as the storage key so
55    /// that multiple instances of the same filter type — including
56    /// filters in branch chains — get independent state.
57    ///
58    /// [`FilterPipeline`]: crate::FilterPipeline
59    pub current_filter_id: Option<usize>,
60
61    /// Whether the downstream connection uses TLS.
62    ///
63    /// Set by the protocol layer from the connection's SSL
64    /// digest. Used by the forwarded headers filter to derive
65    /// `X-Forwarded-Proto` from the actual connection state
66    /// rather than the request URI scheme (which is absent
67    /// in HTTP/1.1).
68    pub downstream_tls: bool,
69
70    /// Type-safe request-scoped extension container.
71    ///
72    /// Filters store and retrieve arbitrary typed values that
73    /// persist across all Pingora lifecycle phases (request,
74    /// request body, response, response body, logging). Keyed
75    /// by [`TypeId`], so only one value per concrete type. Use
76    /// private newtypes to avoid collisions between independent
77    /// filters.
78    ///
79    /// [`TypeId`]: std::any::TypeId
80    pub extensions: RequestExtensions,
81
82    /// Tracks which pipeline filter indices actually executed
83    /// during the request phase. The response phase skips
84    /// filters that did not run (e.g. due to `SkipTo`).
85    pub executed_filter_indices: Vec<bool>,
86
87    /// Extra headers to inject into the upstream request.
88    pub extra_request_headers: Vec<(Cow<'static, str>, String)>,
89
90    /// Headers to remove from the upstream request.
91    pub request_headers_to_remove: Vec<http::header::HeaderName>,
92
93    /// Headers to set (overwrite) on the upstream request.
94    pub request_headers_to_set: Vec<(http::header::HeaderName, http::header::HeaderValue)>,
95
96    /// Durable per-request metadata that persists across all
97    /// Pingora lifecycle phases (request, request-body, response,
98    /// response-body, logging). Unlike [`filter_results`] which
99    /// are cleared after branch evaluation, metadata survives
100    /// for the entire request lifetime.
101    ///
102    /// Keys use dot-prefix namespacing by convention
103    /// (e.g. `json_rpc.kind`, `classifier.label`).
104    ///
105    /// [`filter_results`]: Self::filter_results
106    pub filter_metadata: HashMap<String, String>,
107
108    /// Filter result map: `filter_name` -> result entries.
109    ///
110    /// Filters write string key-value pairs here during
111    /// `on_request` or `on_response`. The pipeline executor
112    /// reads these to evaluate branch conditions. Cleared
113    /// after branch evaluation at each filter.
114    pub filter_results: HashMap<&'static str, FilterResultSet>,
115
116    /// Typed per-filter state that persists across all lifecycle
117    /// phases (request, request-body, response, response-body).
118    ///
119    /// Keyed by stable filter invocation ID, unique within the
120    /// request's pinned [`FilterPipeline`]. Swapped into each
121    /// `HttpFilterContext` from the protocol-layer request context
122    /// and written back after filter execution, following the same
123    /// pattern as [`filter_metadata`].
124    ///
125    /// [`FilterPipeline`]: crate::FilterPipeline
126    /// [`filter_metadata`]: Self::filter_metadata
127    pub filter_state: HashMap<usize, Box<dyn Any + Send + Sync>>,
128
129    /// Shared health registry for endpoint health lookups.
130    pub health_registry: Option<&'a HealthRegistry>,
131
132    /// Shared request ID generator.
133    pub id_generator: &'a IdGenerator,
134
135    /// Named key-value stores for runtime mappings.
136    pub kv_stores: Option<&'a KvStoreRegistry>,
137
138    /// Transport-agnostic request headers, URI, and method.
139    pub request: &'a Request,
140
141    /// Accumulated request body bytes seen so far.
142    pub request_body_bytes: u64,
143
144    /// Per-request body delivery mode for the request direction.
145    /// Defaults to [`BodyMode::Stream`]; filters may upgrade it
146    /// via [`set_request_body_mode`].
147    ///
148    /// [`set_request_body_mode`]: Self::set_request_body_mode
149    pub request_body_mode: BodyMode,
150
151    /// When the request was received; available in all phases.
152    pub request_start: Instant,
153
154    /// Accumulated response body bytes seen so far.
155    pub response_body_bytes: u64,
156
157    /// Per-request body delivery mode for the response direction.
158    /// Defaults to [`BodyMode::Stream`]; filters may upgrade it
159    /// via [`set_response_body_mode`].
160    ///
161    /// [`set_response_body_mode`]: Self::set_response_body_mode
162    pub response_body_mode: BodyMode,
163
164    /// The upstream response headers, available during `on_response`.
165    /// `None` during the request phase.
166    pub response_header: Option<&'a mut Response>,
167
168    /// Whether any filter modified the response headers during
169    /// `on_response`. Used to skip unnecessary work.
170    pub response_headers_modified: bool,
171
172    /// Index of the selected endpoint in the cluster's
173    /// endpoint list. Set by the load balancer filter
174    /// for use by passive health checking in the
175    /// protocol layer.
176    pub selected_endpoint_index: Option<usize>,
177
178    /// Wall-clock time source for timestamp generation.
179    pub time_source: &'a dyn TimeSource,
180
181    /// Rewritten URI path for the upstream request.
182    ///
183    /// Set by the `path_rewrite` or `url_rewrite` filter during
184    /// `on_request`. Applied to the upstream `RequestHeader` in the
185    /// protocol layer.
186    ///
187    /// The router checks this field before the original request URI.
188    /// If a preceding filter sets `rewritten_path`, the router
189    /// matches against it, enabling "rewrite then route" pipelines.
190    ///
191    /// If both `path_rewrite` and `url_rewrite` appear in the same
192    /// pipeline, only the last writer's value takes effect.
193    /// Pipeline validation rejects this by default; set
194    /// `allow_rewrite_override: true` on the later filter to
195    /// permit it. Or, better yet, don't.
196    pub rewritten_path: Option<String>,
197
198    /// The upstream peer selected by the load balancer filter.
199    pub upstream: Option<Upstream>,
200}
201
202impl HttpFilterContext<'_> {
203    /// Selected cluster name, if any.
204    pub fn cluster_name(&self) -> Option<&str> {
205        self.cluster.as_deref()
206    }
207
208    /// Upstream peer address, if selected.
209    pub fn upstream_addr(&self) -> Option<&str> {
210        self.upstream.as_ref().map(|u| &*u.address)
211    }
212
213    /// Read a durable metadata value by key.
214    pub fn get_metadata(&self, key: &str) -> Option<&str> {
215        self.filter_metadata.get(key).map(String::as_str)
216    }
217
218    /// X-Request-ID header value, if present and valid UTF-8.
219    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    /// Write a durable metadata value that persists across all phases.
224    ///
225    /// Keys should use dot-prefix namespacing
226    /// (e.g. `json_rpc.kind`, `classifier.label`). Keys are limited to
227    /// 64 bytes and values to 256 bytes to bound per-request
228    /// memory growth.
229    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    /// Upgrade the request body delivery mode for this request.
252    ///
253    /// Merges `mode` into the current mode using ratchet-up
254    /// semantics: `StreamBuffer > SizeLimit > Stream`. A mode
255    /// can only be upgraded, never downgraded.
256    pub fn set_request_body_mode(&mut self, mode: BodyMode) {
257        merge_body_mode(&mut self.request_body_mode, mode);
258    }
259
260    /// Upgrade the response body delivery mode for this request.
261    ///
262    /// Same ratchet-up semantics as [`set_request_body_mode`].
263    ///
264    /// [`set_request_body_mode`]: Self::set_request_body_mode
265    pub fn set_response_body_mode(&mut self, mode: BodyMode) {
266        merge_body_mode(&mut self.response_body_mode, mode);
267    }
268
269    /// Store typed per-request state for the currently executing filter.
270    ///
271    /// Uses [`current_filter_id`] as the storage key, so multiple
272    /// instances of the same filter type get independent state.
273    ///
274    /// No-op if called outside of pipeline execution (when
275    /// [`current_filter_id`] is `None`).
276    ///
277    /// [`current_filter_id`]: Self::current_filter_id
278    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    /// Retrieve a shared reference to the typed state stored by the
287    /// currently executing filter.
288    ///
289    /// Returns `None` when no state is stored, when the stored type
290    /// does not match `T`, or when called outside pipeline execution.
291    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    /// Retrieve a mutable reference to the typed state stored by the
297    /// currently executing filter.
298    ///
299    /// Returns `None` under the same conditions as
300    /// [`get_filter_state`].
301    ///
302    /// [`get_filter_state`]: Self::get_filter_state
303    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    /// Remove and return the typed state stored by the currently
309    /// executing filter.
310    ///
311    /// Returns `None` when no state is stored, when the stored type
312    /// does not match `T`, or when called outside pipeline execution.
313    /// A type mismatch does not destroy the stored entry.
314    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// -----------------------------------------------------------------------------
325// Request
326// -----------------------------------------------------------------------------
327
328/// HTTP request metadata.
329///
330/// ```
331/// use http::{HeaderMap, Method, Uri};
332/// use praxis_filter::Request;
333///
334/// let req = Request {
335///     method: Method::GET,
336///     uri: Uri::from_static("/api/users"),
337///     headers: HeaderMap::new(),
338/// };
339/// assert_eq!(req.uri.path(), "/api/users");
340/// ```
341#[derive(Debug, Clone)]
342pub struct Request {
343    /// HTTP header map.
344    pub headers: HeaderMap,
345
346    /// HTTP method.
347    pub method: Method,
348
349    /// Request URI.
350    pub uri: Uri,
351}
352
353// -----------------------------------------------------------------------------
354// Response
355// -----------------------------------------------------------------------------
356
357/// HTTP response metadata.
358///
359/// ```
360/// use http::{HeaderMap, StatusCode};
361/// use praxis_filter::Response;
362///
363/// let mut resp = Response {
364///     status: StatusCode::OK,
365///     headers: HeaderMap::new(),
366/// };
367/// resp.headers.insert("x-custom", "value".parse().unwrap());
368/// assert_eq!(resp.status, StatusCode::OK);
369/// ```
370#[derive(Debug)]
371pub struct Response {
372    /// HTTP header map.
373    pub headers: HeaderMap,
374
375    /// HTTP status code.
376    pub status: StatusCode,
377}
378
379// -----------------------------------------------------------------------------
380// Tests
381// -----------------------------------------------------------------------------
382
383#[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(&registry);
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(&registry);
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(&registry);
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(&registry);
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    // -------------------------------------------------------------------------
774    // Filter State Tests
775    // -------------------------------------------------------------------------
776
777    #[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}