use std::{any::Any, borrow::Cow, collections::HashMap, net::IpAddr, sync::Arc, time::Instant};
use http::{HeaderMap, Method, StatusCode, Uri};
use praxis_core::{
connectivity::Upstream, health::HealthRegistry, id::IdGenerator, kv::KvStoreRegistry, time::TimeSource,
};
use crate::{body::BodyMode, extensions::RequestExtensions, pipeline::body::merge_body_mode, results::FilterResultSet};
const MAX_METADATA_ENTRIES: usize = 128;
pub struct HttpFilterContext<'a> {
pub body_done_indices: Vec<bool>,
pub branch_iterations: HashMap<Arc<str>, u32>,
pub client_addr: Option<IpAddr>,
pub cluster: Option<Arc<str>>,
pub current_filter_id: Option<usize>,
pub downstream_tls: bool,
pub extensions: RequestExtensions,
pub executed_filter_indices: Vec<bool>,
pub extra_request_headers: Vec<(Cow<'static, str>, String)>,
pub request_headers_to_remove: Vec<http::header::HeaderName>,
pub request_headers_to_set: Vec<(http::header::HeaderName, http::header::HeaderValue)>,
pub filter_metadata: HashMap<String, String>,
pub filter_results: HashMap<&'static str, FilterResultSet>,
pub filter_state: HashMap<usize, Box<dyn Any + Send + Sync>>,
pub health_registry: Option<&'a HealthRegistry>,
pub id_generator: &'a IdGenerator,
pub kv_stores: Option<&'a KvStoreRegistry>,
pub request: &'a Request,
pub request_body_bytes: u64,
pub request_body_mode: BodyMode,
pub request_start: Instant,
pub response_body_bytes: u64,
pub response_body_mode: BodyMode,
pub response_header: Option<&'a mut Response>,
pub response_headers_modified: bool,
pub selected_endpoint_index: Option<usize>,
pub time_source: &'a dyn TimeSource,
pub rewritten_path: Option<String>,
pub upstream: Option<Upstream>,
}
impl HttpFilterContext<'_> {
pub fn cluster_name(&self) -> Option<&str> {
self.cluster.as_deref()
}
pub fn upstream_addr(&self) -> Option<&str> {
self.upstream.as_ref().map(|u| &*u.address)
}
pub fn get_metadata(&self, key: &str) -> Option<&str> {
self.filter_metadata.get(key).map(String::as_str)
}
pub fn request_id(&self) -> Option<&str> {
self.request.headers.get("x-request-id").and_then(|v| v.to_str().ok())
}
pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
let key = key.into();
let value = value.into();
if key.is_empty() || key.len() > 64 {
tracing::warn!(key_len = key.len(), "metadata key rejected (must be 1-64 bytes)");
return;
}
if value.len() > 256 {
tracing::warn!(key = %key, value_len = value.len(), "metadata value rejected (max 256 bytes)");
return;
}
if !self.filter_metadata.contains_key(&key) && self.filter_metadata.len() >= MAX_METADATA_ENTRIES {
tracing::warn!(
key = %key,
entries = self.filter_metadata.len(),
"metadata entry rejected (max {MAX_METADATA_ENTRIES} entries)"
);
return;
}
self.filter_metadata.insert(key, value);
}
pub fn set_request_body_mode(&mut self, mode: BodyMode) {
merge_body_mode(&mut self.request_body_mode, mode);
}
pub fn set_response_body_mode(&mut self, mode: BodyMode) {
merge_body_mode(&mut self.response_body_mode, mode);
}
pub fn insert_filter_state<T: Any + Send + Sync>(&mut self, state: T) {
let Some(idx) = self.current_filter_id else {
tracing::warn!("insert_filter_state called outside pipeline execution");
return;
};
self.filter_state.insert(idx, Box::new(state));
}
pub fn get_filter_state<T: Any + Send + Sync>(&self) -> Option<&T> {
let idx = self.current_filter_id?;
self.filter_state.get(&idx)?.downcast_ref()
}
pub fn get_filter_state_mut<T: Any + Send + Sync>(&mut self) -> Option<&mut T> {
let idx = self.current_filter_id?;
self.filter_state.get_mut(&idx)?.downcast_mut()
}
pub fn remove_filter_state<T: Any + Send + Sync>(&mut self) -> Option<T> {
let idx = self.current_filter_id?;
if !self.filter_state.get(&idx)?.as_ref().is::<T>() {
return None;
}
let boxed = self.filter_state.remove(&idx)?;
Some(*boxed.downcast::<T>().ok()?)
}
}
#[derive(Debug, Clone)]
pub struct Request {
pub headers: HeaderMap,
pub method: Method,
pub uri: Uri,
}
#[derive(Debug)]
pub struct Response {
pub headers: HeaderMap,
pub status: StatusCode,
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
reason = "tests"
)]
mod tests {
use super::*;
#[test]
fn request_fields_are_accessible() {
let req = Request {
method: Method::POST,
uri: "/submit".parse().unwrap(),
headers: HeaderMap::new(),
};
assert_eq!(req.method, Method::POST);
assert_eq!(req.uri.path(), "/submit");
assert!(req.headers.is_empty(), "new request should have no headers");
}
#[test]
fn response_header_mutation() {
let mut resp = Response {
status: StatusCode::OK,
headers: HeaderMap::new(),
};
resp.headers.insert("x-powered-by", "praxis".parse().unwrap());
assert_eq!(resp.headers["x-powered-by"], "praxis");
}
#[test]
fn response_status_codes() {
for code in [200_u16, 404, 500] {
let resp = Response {
status: StatusCode::from_u16(code).unwrap(),
headers: HeaderMap::new(),
};
assert_eq!(resp.status.as_u16(), code);
}
}
#[test]
fn cluster_name_returns_none_when_unset() {
let req = crate::test_utils::make_request(Method::GET, "/");
let ctx = crate::test_utils::make_filter_context(&req);
assert!(ctx.cluster_name().is_none(), "cluster name should be None when unset");
}
#[test]
fn cluster_name_returns_value_when_set() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.cluster = Some(Arc::from("backend"));
assert_eq!(
ctx.cluster_name(),
Some("backend"),
"cluster name should return set value"
);
}
#[test]
fn upstream_addr_returns_none_when_unset() {
let req = crate::test_utils::make_request(Method::GET, "/");
let ctx = crate::test_utils::make_filter_context(&req);
assert!(ctx.upstream_addr().is_none(), "upstream addr should be None when unset");
}
#[test]
fn upstream_addr_returns_value_when_set() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.upstream = Some(Upstream {
address: Arc::from("10.0.0.1:8080"),
tls: None,
connection: Arc::new(praxis_core::connectivity::ConnectionOptions::default()),
});
assert_eq!(
ctx.upstream_addr(),
Some("10.0.0.1:8080"),
"upstream addr should return set address"
);
}
#[test]
fn request_id_returns_none_when_absent() {
let req = crate::test_utils::make_request(Method::GET, "/");
let ctx = crate::test_utils::make_filter_context(&req);
assert!(
ctx.request_id().is_none(),
"request ID should be None when header absent"
);
}
#[test]
fn request_id_returns_value_when_present() {
let mut req = crate::test_utils::make_request(Method::GET, "/");
req.headers.insert("x-request-id", "abc-123".parse().unwrap());
let ctx = crate::test_utils::make_filter_context(&req);
assert_eq!(
ctx.request_id(),
Some("abc-123"),
"request ID should return header value"
);
}
#[test]
fn set_request_body_mode_upgrades_stream_to_stream_buffer() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
assert_eq!(ctx.request_body_mode, BodyMode::Stream, "should start as Stream");
ctx.set_request_body_mode(BodyMode::StreamBuffer { max_bytes: Some(4096) });
assert_eq!(
ctx.request_body_mode,
BodyMode::StreamBuffer { max_bytes: Some(4096) },
"Stream should upgrade to StreamBuffer"
);
}
#[test]
fn set_request_body_mode_cannot_downgrade() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.set_request_body_mode(BodyMode::StreamBuffer { max_bytes: Some(2048) });
ctx.set_request_body_mode(BodyMode::Stream);
assert_eq!(
ctx.request_body_mode,
BodyMode::StreamBuffer { max_bytes: Some(2048) },
"StreamBuffer should not downgrade to Stream"
);
}
#[test]
fn set_response_body_mode_upgrades_stream_to_stream_buffer() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
assert_eq!(ctx.response_body_mode, BodyMode::Stream, "should start as Stream");
ctx.set_response_body_mode(BodyMode::StreamBuffer { max_bytes: Some(8192) });
assert_eq!(
ctx.response_body_mode,
BodyMode::StreamBuffer { max_bytes: Some(8192) },
"Stream should upgrade to StreamBuffer"
);
}
#[test]
fn set_request_body_mode_stream_buffer_then_stream_buffer_merges_limits() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.set_request_body_mode(BodyMode::StreamBuffer { max_bytes: Some(2048) });
ctx.set_request_body_mode(BodyMode::StreamBuffer { max_bytes: Some(1024) });
assert_eq!(
ctx.request_body_mode,
BodyMode::StreamBuffer { max_bytes: Some(2048) },
"larger StreamBuffer limit should win when merging"
);
}
#[test]
fn get_metadata_returns_none_when_empty() {
let req = crate::test_utils::make_request(Method::GET, "/");
let ctx = crate::test_utils::make_filter_context(&req);
assert!(
ctx.get_metadata("json_rpc.method").is_none(),
"get_metadata should return None for absent key"
);
}
#[test]
fn set_metadata_then_get_returns_value() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.set_metadata("json_rpc.method", "service/invoke");
assert_eq!(
ctx.get_metadata("json_rpc.method"),
Some("service/invoke"),
"get_metadata should return the set value"
);
}
#[test]
fn set_metadata_overwrites_existing() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.set_metadata("classifier.label", "ProcessRequest");
ctx.set_metadata("classifier.label", "GetTask");
assert_eq!(
ctx.get_metadata("classifier.label"),
Some("GetTask"),
"set_metadata should overwrite previous value"
);
}
#[test]
fn metadata_independent_of_filter_results() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.set_metadata("request.session_id", "gw-123");
ctx.filter_results.clear();
assert_eq!(
ctx.get_metadata("request.session_id"),
Some("gw-123"),
"clearing filter_results should not affect metadata"
);
}
#[test]
fn set_metadata_accepts_owned_strings() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
let key = "request.task_id".to_owned();
let value = "task-456".to_owned();
ctx.set_metadata(key, value);
assert_eq!(
ctx.get_metadata("request.task_id"),
Some("task-456"),
"set_metadata should accept owned Strings"
);
}
#[test]
fn kv_stores_returns_none_when_unset() {
let req = crate::test_utils::make_request(Method::GET, "/");
let ctx = crate::test_utils::make_filter_context(&req);
assert!(ctx.kv_stores.is_none(), "kv_stores should be None when unset");
}
#[test]
fn kv_stores_returns_registry_when_set() {
let registry = KvStoreRegistry::new();
let store = registry.get_or_create("routing");
store.set("model", Arc::from("model-gamma-1"));
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.kv_stores = Some(®istry);
let store = ctx.kv_stores.unwrap().get("routing").unwrap();
assert_eq!(
store.get("model").as_deref(),
Some("model-gamma-1"),
"filter should read KV store via context"
);
}
#[test]
fn kv_stores_write_from_context_is_visible() {
let registry = KvStoreRegistry::new();
let store = registry.get_or_create("flags");
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.kv_stores = Some(®istry);
ctx.kv_stores
.unwrap()
.get("flags")
.unwrap()
.set("dark_mode", Arc::from("true"));
assert_eq!(
store.get("dark_mode").as_deref(),
Some("true"),
"write through context should be visible on the original store"
);
}
#[test]
fn kv_stores_missing_store_returns_none() {
let registry = KvStoreRegistry::new();
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.kv_stores = Some(®istry);
assert!(
ctx.kv_stores.unwrap().get("nonexistent").is_none(),
"missing store name should return None"
);
}
#[test]
fn set_metadata_rejects_empty_key() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.set_metadata("", "val");
assert!(ctx.get_metadata("").is_none(), "empty key should be silently rejected");
}
#[test]
fn set_metadata_rejects_long_key() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
let long_key = "k".repeat(65);
ctx.set_metadata(long_key.as_str(), "val");
assert!(
ctx.get_metadata(long_key.as_str()).is_none(),
"65-byte key should be rejected"
);
}
#[test]
fn set_metadata_accepts_max_length_key() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
let max_key = "k".repeat(64);
ctx.set_metadata(max_key.as_str(), "val");
assert_eq!(
ctx.get_metadata(max_key.as_str()),
Some("val"),
"64-byte key should be accepted"
);
}
#[test]
fn set_metadata_rejects_long_value() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
let long_value = "v".repeat(257);
ctx.set_metadata("key", long_value.as_str());
assert!(ctx.get_metadata("key").is_none(), "257-byte value should be rejected");
}
#[test]
fn set_metadata_rejects_when_entry_limit_reached() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
for i in 0..MAX_METADATA_ENTRIES {
ctx.set_metadata(format!("key.{i}"), "value");
}
assert_eq!(
ctx.filter_metadata.len(),
MAX_METADATA_ENTRIES,
"should accept exactly {MAX_METADATA_ENTRIES} entries"
);
ctx.set_metadata("overflow", "value");
assert!(
ctx.get_metadata("overflow").is_none(),
"entry beyond limit should be rejected"
);
}
#[test]
fn set_metadata_allows_overwrite_at_limit() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
for i in 0..MAX_METADATA_ENTRIES {
ctx.set_metadata(format!("key.{i}"), "old");
}
ctx.set_metadata("key.0", "new");
assert_eq!(
ctx.get_metadata("key.0"),
Some("new"),
"overwriting existing key at limit should succeed"
);
assert_eq!(
ctx.filter_metadata.len(),
MAX_METADATA_ENTRIES,
"overwrite should not increase entry count"
);
}
#[test]
fn kv_stores_lookup_with_match_types() {
use praxis_core::kv::MatchType;
let registry = KvStoreRegistry::new();
let store = registry.get_or_create("routes");
store.set("route.api.v1", Arc::from("api_cluster"));
store.set("route.web.main", Arc::from("web_cluster"));
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.kv_stores = Some(®istry);
let store = ctx.kv_stores.unwrap().get("routes").unwrap();
assert!(
store.lookup("route.api", MatchType::Prefix).unwrap().is_some(),
"prefix lookup should match route.api.v1"
);
assert!(
store.lookup(".main", MatchType::Suffix).unwrap().is_some(),
"suffix lookup should match route.web.main"
);
}
#[test]
fn insert_and_get_filter_state_returns_typed_value() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.current_filter_id = Some(0);
ctx.insert_filter_state(42_u64);
assert_eq!(
ctx.get_filter_state::<u64>(),
Some(&42_u64),
"should return the inserted value"
);
}
#[test]
fn get_filter_state_returns_none_when_empty() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.current_filter_id = Some(0);
assert!(
ctx.get_filter_state::<u64>().is_none(),
"should return None when no state stored"
);
}
#[test]
fn get_filter_state_returns_none_for_wrong_type() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.current_filter_id = Some(0);
ctx.insert_filter_state(42_u64);
assert!(
ctx.get_filter_state::<String>().is_none(),
"should return None for type mismatch"
);
}
#[test]
fn get_filter_state_returns_none_when_no_index() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.filter_state.insert(0, Box::new(42_u64));
assert!(
ctx.get_filter_state::<u64>().is_none(),
"should return None when current_filter_id is None"
);
}
#[test]
fn get_filter_state_mut_allows_mutation() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.current_filter_id = Some(0);
ctx.insert_filter_state(10_u64);
*ctx.get_filter_state_mut::<u64>().unwrap() += 5;
assert_eq!(
ctx.get_filter_state::<u64>(),
Some(&15_u64),
"mutation through get_mut should be visible"
);
}
#[test]
fn remove_filter_state_takes_ownership() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.current_filter_id = Some(0);
ctx.insert_filter_state("hello".to_owned());
let removed = ctx.remove_filter_state::<String>();
assert_eq!(removed.as_deref(), Some("hello"), "should return the stored value");
assert!(
ctx.get_filter_state::<String>().is_none(),
"state should be gone after remove"
);
}
#[test]
fn remove_filter_state_returns_none_for_wrong_type() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.current_filter_id = Some(0);
ctx.insert_filter_state(42_u64);
assert!(
ctx.remove_filter_state::<String>().is_none(),
"type mismatch should return None"
);
assert!(
ctx.get_filter_state::<u64>().is_some(),
"type mismatch remove should not destroy the entry"
);
}
#[test]
fn different_indices_do_not_collide() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.current_filter_id = Some(0);
ctx.insert_filter_state(100_u64);
ctx.current_filter_id = Some(1);
ctx.insert_filter_state(200_u64);
ctx.current_filter_id = Some(0);
assert_eq!(ctx.get_filter_state::<u64>(), Some(&100_u64), "index 0 state");
ctx.current_filter_id = Some(1);
assert_eq!(ctx.get_filter_state::<u64>(), Some(&200_u64), "index 1 state");
}
#[test]
fn insert_filter_state_is_noop_without_index() {
let req = crate::test_utils::make_request(Method::GET, "/");
let mut ctx = crate::test_utils::make_filter_context(&req);
ctx.insert_filter_state(42_u64);
assert!(ctx.filter_state.is_empty(), "state map should remain empty");
}
}