use std::{collections::HashMap, sync::Arc};
use arc_swap::ArcSwap;
use praxis_core::config::ProtocolKind;
use praxis_filter::FilterPipeline;
pub struct ListenerPipelines {
pipelines: HashMap<String, Arc<ArcSwap<FilterPipeline>>>,
protocols: HashMap<String, ProtocolKind>,
}
impl ListenerPipelines {
pub fn new(pipelines: HashMap<String, Arc<FilterPipeline>>) -> Self {
Self::with_protocols(pipelines, HashMap::new())
}
pub fn with_protocols(
pipelines: HashMap<String, Arc<FilterPipeline>>,
protocols: HashMap<String, ProtocolKind>,
) -> Self {
let swappable = pipelines
.into_iter()
.map(|(name, p)| (name, Arc::new(ArcSwap::from(p))))
.collect();
Self {
pipelines: swappable,
protocols,
}
}
pub fn protocol(&self, listener_name: &str) -> Option<ProtocolKind> {
self.protocols.get(listener_name).copied()
}
pub fn get(&self, listener_name: &str) -> Option<&Arc<ArcSwap<FilterPipeline>>> {
self.pipelines.get(listener_name)
}
pub fn swap(&self, listener_name: &str, new_pipeline: Arc<FilterPipeline>) {
if let Some(slot) = self.pipelines.get(listener_name) {
slot.store(new_pipeline);
}
}
pub fn referenced_files(&self) -> Vec<std::path::PathBuf> {
let mut seen = std::collections::BTreeSet::new();
for name in self.listener_names() {
if let Some(slot) = self.get(name) {
for path in slot.load().referenced_files() {
seen.insert(path);
}
}
}
seen.into_iter().collect()
}
pub fn listener_names(&self) -> impl Iterator<Item = &str> {
self.pipelines.keys().map(String::as_str)
}
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::too_many_lines,
reason = "tests"
)]
mod tests {
use praxis_filter::FilterRegistry;
use super::*;
#[test]
fn get_returns_pipeline() {
let pipelines = make_pipelines(&["web"]);
assert!(pipelines.get("web").is_some(), "should find 'web' pipeline");
}
#[test]
fn get_returns_none_for_missing() {
let pipelines = make_pipelines(&["web"]);
assert!(pipelines.get("missing").is_none(), "should return None for missing");
}
#[test]
fn swap_replaces_pipeline_pointer() {
let pipelines = make_pipelines(&["web"]);
let old_ptr = Arc::as_ptr(&pipelines.get("web").unwrap().load());
let registry = FilterRegistry::with_builtins();
let new_pipeline = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
pipelines.swap("web", Arc::clone(&new_pipeline));
let new_ptr = Arc::as_ptr(&pipelines.get("web").unwrap().load());
assert_ne!(old_ptr, new_ptr, "swap should replace the pipeline pointer");
}
#[test]
fn old_guard_remains_valid_after_swap() {
let pipelines = make_pipelines(&["web"]);
let old_guard = pipelines.get("web").unwrap().load();
let old_ptr = Arc::as_ptr(&old_guard);
let registry = FilterRegistry::with_builtins();
let new_pipeline = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
pipelines.swap("web", new_pipeline);
let still_old_ptr = Arc::as_ptr(&old_guard);
assert_eq!(
old_ptr, still_old_ptr,
"old guard should still point to the original pipeline"
);
}
#[test]
fn swap_nonexistent_is_noop() {
let pipelines = make_pipelines(&["web"]);
let registry = FilterRegistry::with_builtins();
let new_pipeline = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
pipelines.swap("nonexistent", new_pipeline);
assert!(pipelines.get("web").is_some(), "existing pipeline should be unaffected");
}
#[test]
fn get_returns_arcswap_reference() {
let pipelines = make_pipelines(&["web"]);
let slot: &Arc<ArcSwap<FilterPipeline>> = pipelines.get("web").unwrap();
let _loaded: arc_swap::Guard<Arc<FilterPipeline>> = slot.load();
}
#[test]
fn protocol_is_recorded_at_construction() {
let registry = FilterRegistry::with_builtins();
let mut map = HashMap::new();
map.insert(
"web".to_owned(),
Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap()),
);
let mut protocols = HashMap::new();
protocols.insert("web".to_owned(), ProtocolKind::Tcp);
let pipelines = ListenerPipelines::with_protocols(map, protocols);
assert_eq!(pipelines.protocol("web"), Some(ProtocolKind::Tcp));
assert_eq!(
pipelines.protocol("missing"),
None,
"unknown listeners have no protocol"
);
}
#[test]
fn protocol_survives_swap() {
let registry = FilterRegistry::with_builtins();
let mut map = HashMap::new();
map.insert(
"web".to_owned(),
Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap()),
);
let mut protocols = HashMap::new();
protocols.insert("web".to_owned(), ProtocolKind::Http);
let pipelines = ListenerPipelines::with_protocols(map, protocols);
pipelines.swap("web", Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap()));
assert_eq!(
pipelines.protocol("web"),
Some(ProtocolKind::Http),
"a swap replaces the pipeline, never the protocol the handler was bound for"
);
}
#[test]
fn new_records_no_protocol() {
let pipelines = make_pipelines(&["web"]);
assert_eq!(pipelines.protocol("web"), None);
}
#[test]
fn referenced_files_empty_without_listeners() {
let pipelines = make_pipelines(&[]);
assert!(
pipelines.referenced_files().is_empty(),
"no listeners means no referenced documents"
);
}
#[test]
fn referenced_files_empty_when_no_filter_declares_one() {
let pipelines = make_pipelines(&["web"]);
assert!(
pipelines.referenced_files().is_empty(),
"a pipeline of non-declaring filters contributes nothing"
);
}
#[test]
fn referenced_files_collects_across_listeners() {
let pipelines =
make_pipelines_with_documents(&[("web", "/etc/praxis/web.yaml"), ("api", "/etc/praxis/api.yaml")]);
assert_eq!(
pipelines.referenced_files(),
vec![
std::path::PathBuf::from("/etc/praxis/api.yaml"),
std::path::PathBuf::from("/etc/praxis/web.yaml"),
],
"every listener's documents must be collected, sorted by the BTreeSet"
);
}
#[test]
fn referenced_files_dedupes_a_document_shared_by_two_listeners() {
let shared = "/etc/praxis/shared.yaml";
let pipelines = make_pipelines_with_documents(&[("web", shared), ("api", shared)]);
assert_eq!(
pipelines.referenced_files(),
vec![std::path::PathBuf::from(shared)],
"a shared document must appear once"
);
}
struct DocumentReaderFilter {
document: std::path::PathBuf,
}
#[async_trait::async_trait]
impl praxis_filter::HttpFilter for DocumentReaderFilter {
fn name(&self) -> &'static str {
"document_reader"
}
fn referenced_files(&self) -> Vec<std::path::PathBuf> {
vec![self.document.clone()]
}
async fn on_request(
&self,
_ctx: &mut praxis_filter::HttpFilterContext<'_>,
) -> Result<praxis_filter::FilterAction, praxis_filter::FilterError> {
Ok(praxis_filter::FilterAction::Continue)
}
}
fn make_pipelines_with_documents(listeners: &[(&str, &str)]) -> ListenerPipelines {
let mut registry = FilterRegistry::with_builtins();
registry
.register(
"document_reader",
praxis_filter::FilterFactory::Http(Arc::new(|cfg: &serde_yaml::Value| {
let document = cfg
.get("document")
.and_then(serde_yaml::Value::as_str)
.ok_or_else(|| praxis_filter::FilterError::from("document_reader: missing document"))?;
let filter: Box<dyn praxis_filter::HttpFilter> = Box::new(DocumentReaderFilter {
document: std::path::PathBuf::from(document),
});
Ok(filter)
})),
)
.unwrap();
let mut map = HashMap::new();
for (listener, document) in listeners {
let yaml = format!("- filter: document_reader\n document: {document}\n");
let mut entries: Vec<praxis_core::config::FilterEntry> = serde_yaml::from_str(&yaml).unwrap();
let pipeline = Arc::new(FilterPipeline::build(&mut entries, ®istry).unwrap());
map.insert((*listener).to_owned(), pipeline);
}
ListenerPipelines::new(map)
}
fn make_pipelines(names: &[&str]) -> ListenerPipelines {
let registry = FilterRegistry::with_builtins();
let mut map = HashMap::new();
for name in names {
let pipeline = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
map.insert((*name).to_owned(), pipeline);
}
ListenerPipelines::new(map)
}
}