praxis_protocol/
pipelines.rs1use std::{collections::HashMap, sync::Arc};
19
20use arc_swap::ArcSwap;
21use praxis_filter::FilterPipeline;
22
23pub struct ListenerPipelines {
51 pipelines: HashMap<String, Arc<ArcSwap<FilterPipeline>>>,
53}
54
55impl ListenerPipelines {
56 pub fn new(pipelines: HashMap<String, Arc<FilterPipeline>>) -> Self {
58 let swappable = pipelines
59 .into_iter()
60 .map(|(name, p)| (name, Arc::new(ArcSwap::from(p))))
61 .collect();
62 Self { pipelines: swappable }
63 }
64
65 pub fn get(&self, listener_name: &str) -> Option<&Arc<ArcSwap<FilterPipeline>>> {
67 self.pipelines.get(listener_name)
68 }
69
70 pub fn swap(&self, listener_name: &str, new_pipeline: Arc<FilterPipeline>) {
95 if let Some(slot) = self.pipelines.get(listener_name) {
96 slot.store(new_pipeline);
97 }
98 }
99
100 pub fn referenced_files(&self) -> Vec<std::path::PathBuf> {
106 let mut seen = std::collections::BTreeSet::new();
107 for name in self.listener_names() {
108 if let Some(slot) = self.get(name) {
109 for path in slot.load().referenced_files() {
110 seen.insert(path);
111 }
112 }
113 }
114 seen.into_iter().collect()
115 }
116
117 pub fn listener_names(&self) -> impl Iterator<Item = &str> {
119 self.pipelines.keys().map(String::as_str)
120 }
121}
122
123#[cfg(test)]
128#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
129#[allow(
130 clippy::unwrap_used,
131 clippy::expect_used,
132 clippy::indexing_slicing,
133 clippy::too_many_lines,
134 reason = "tests"
135)]
136mod tests {
137 use praxis_filter::FilterRegistry;
138
139 use super::*;
140
141 #[test]
142 fn get_returns_pipeline() {
143 let pipelines = make_pipelines(&["web"]);
144 assert!(pipelines.get("web").is_some(), "should find 'web' pipeline");
145 }
146
147 #[test]
148 fn get_returns_none_for_missing() {
149 let pipelines = make_pipelines(&["web"]);
150 assert!(pipelines.get("missing").is_none(), "should return None for missing");
151 }
152
153 #[test]
154 fn swap_replaces_pipeline_pointer() {
155 let pipelines = make_pipelines(&["web"]);
156 let old_ptr = Arc::as_ptr(&pipelines.get("web").unwrap().load());
157
158 let registry = FilterRegistry::with_builtins();
159 let new_pipeline = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
160 pipelines.swap("web", Arc::clone(&new_pipeline));
161
162 let new_ptr = Arc::as_ptr(&pipelines.get("web").unwrap().load());
163 assert_ne!(old_ptr, new_ptr, "swap should replace the pipeline pointer");
164 }
165
166 #[test]
167 fn old_guard_remains_valid_after_swap() {
168 let pipelines = make_pipelines(&["web"]);
169 let old_guard = pipelines.get("web").unwrap().load();
170 let old_ptr = Arc::as_ptr(&old_guard);
171
172 let registry = FilterRegistry::with_builtins();
173 let new_pipeline = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
174 pipelines.swap("web", new_pipeline);
175
176 let still_old_ptr = Arc::as_ptr(&old_guard);
177 assert_eq!(
178 old_ptr, still_old_ptr,
179 "old guard should still point to the original pipeline"
180 );
181 }
182
183 #[test]
184 fn swap_nonexistent_is_noop() {
185 let pipelines = make_pipelines(&["web"]);
186 let registry = FilterRegistry::with_builtins();
187 let new_pipeline = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
188 pipelines.swap("nonexistent", new_pipeline);
189 assert!(pipelines.get("web").is_some(), "existing pipeline should be unaffected");
190 }
191
192 #[test]
193 fn get_returns_arcswap_reference() {
194 let pipelines = make_pipelines(&["web"]);
195 let slot: &Arc<ArcSwap<FilterPipeline>> = pipelines.get("web").unwrap();
196 let _loaded: arc_swap::Guard<Arc<FilterPipeline>> = slot.load();
197 }
198
199 #[test]
200 fn referenced_files_empty_without_listeners() {
201 let pipelines = make_pipelines(&[]);
202 assert!(
203 pipelines.referenced_files().is_empty(),
204 "no listeners means no referenced documents"
205 );
206 }
207
208 #[test]
209 fn referenced_files_empty_when_no_filter_declares_one() {
210 let pipelines = make_pipelines(&["web"]);
211 assert!(
212 pipelines.referenced_files().is_empty(),
213 "a pipeline of non-declaring filters contributes nothing"
214 );
215 }
216
217 #[test]
218 fn referenced_files_collects_across_listeners() {
219 let pipelines =
220 make_pipelines_with_documents(&[("web", "/etc/praxis/web.yaml"), ("api", "/etc/praxis/api.yaml")]);
221 assert_eq!(
222 pipelines.referenced_files(),
223 vec![
224 std::path::PathBuf::from("/etc/praxis/api.yaml"),
225 std::path::PathBuf::from("/etc/praxis/web.yaml"),
226 ],
227 "every listener's documents must be collected, sorted by the BTreeSet"
228 );
229 }
230
231 #[test]
234 fn referenced_files_dedupes_a_document_shared_by_two_listeners() {
235 let shared = "/etc/praxis/shared.yaml";
236 let pipelines = make_pipelines_with_documents(&[("web", shared), ("api", shared)]);
237 assert_eq!(
238 pipelines.referenced_files(),
239 vec![std::path::PathBuf::from(shared)],
240 "a shared document must appear once"
241 );
242 }
243
244 struct DocumentReaderFilter {
250 document: std::path::PathBuf,
251 }
252
253 #[async_trait::async_trait]
254 impl praxis_filter::HttpFilter for DocumentReaderFilter {
255 fn name(&self) -> &'static str {
256 "document_reader"
257 }
258
259 fn referenced_files(&self) -> Vec<std::path::PathBuf> {
260 vec![self.document.clone()]
261 }
262
263 async fn on_request(
264 &self,
265 _ctx: &mut praxis_filter::HttpFilterContext<'_>,
266 ) -> Result<praxis_filter::FilterAction, praxis_filter::FilterError> {
267 Ok(praxis_filter::FilterAction::Continue)
268 }
269 }
270
271 fn make_pipelines_with_documents(listeners: &[(&str, &str)]) -> ListenerPipelines {
274 let mut registry = FilterRegistry::with_builtins();
275 registry
276 .register(
277 "document_reader",
278 praxis_filter::FilterFactory::Http(Arc::new(|cfg: &serde_yaml::Value| {
279 let document = cfg
280 .get("document")
281 .and_then(serde_yaml::Value::as_str)
282 .ok_or_else(|| praxis_filter::FilterError::from("document_reader: missing document"))?;
283 let filter: Box<dyn praxis_filter::HttpFilter> = Box::new(DocumentReaderFilter {
284 document: std::path::PathBuf::from(document),
285 });
286 Ok(filter)
287 })),
288 )
289 .unwrap();
290
291 let mut map = HashMap::new();
292 for (listener, document) in listeners {
293 let yaml = format!("- filter: document_reader\n document: {document}\n");
294 let mut entries: Vec<praxis_core::config::FilterEntry> = serde_yaml::from_str(&yaml).unwrap();
295 let pipeline = Arc::new(FilterPipeline::build(&mut entries, ®istry).unwrap());
296 map.insert((*listener).to_owned(), pipeline);
297 }
298 ListenerPipelines::new(map)
299 }
300
301 fn make_pipelines(names: &[&str]) -> ListenerPipelines {
303 let registry = FilterRegistry::with_builtins();
304 let mut map = HashMap::new();
305 for name in names {
306 let pipeline = Arc::new(FilterPipeline::build(&mut [], ®istry).unwrap());
307 map.insert((*name).to_owned(), pipeline);
308 }
309 ListenerPipelines::new(map)
310 }
311}