Skip to main content

praxis_filter/
factory.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Filter factory types: closures that construct filters from YAML config.
5
6use std::sync::Arc;
7
8use serde::de::DeserializeOwned;
9
10use crate::{
11    any_filter::AnyFilter,
12    filter::{FilterError, HttpFilter},
13    tcp_filter::TcpFilter,
14};
15
16// -----------------------------------------------------------------------------
17// Config Parsing
18// -----------------------------------------------------------------------------
19
20/// Parse a YAML config value into a typed config struct.
21///
22/// Clones `config` because [`serde_yaml::from_value`] takes ownership.
23/// This runs only at startup/reload, not per-request.
24///
25/// ```
26/// use praxis_filter::parse_filter_config;
27///
28/// #[derive(serde::Deserialize)]
29/// struct MyCfg {
30///     timeout_ms: u64,
31/// }
32///
33/// let yaml: serde_yaml::Value = serde_yaml::from_str("timeout_ms: 3000").unwrap();
34/// let cfg: MyCfg = parse_filter_config("my_filter", &yaml).unwrap();
35/// assert_eq!(cfg.timeout_ms, 3000);
36/// ```
37/// # Errors
38///
39/// Returns [`FilterError`] if YAML deserialization fails.
40///
41/// [`FilterError`]: crate::FilterError
42pub fn parse_filter_config<T: DeserializeOwned>(name: &str, config: &serde_yaml::Value) -> Result<T, FilterError> {
43    let cleaned = strip_structural_keys(config);
44    serde_yaml::from_value(cleaned).map_err(|e| -> FilterError { format!("{name}: {e}").into() })
45}
46
47/// Remove [`FilterEntry`] structural keys that leak through
48/// `#[serde(flatten)]` into the filter config `Value`.
49///
50/// Without this, filter configs using `#[serde(deny_unknown_fields)]`
51/// would reject keys like `filter`, `conditions`, etc. that belong
52/// to the entry wrapper, not the filter's own config.
53///
54/// [`FilterEntry`]: praxis_core::config::FilterEntry
55fn strip_structural_keys(config: &serde_yaml::Value) -> serde_yaml::Value {
56    const STRUCTURAL: &[&str] = &[
57        "branch_chains",
58        "conditions",
59        "failure_mode",
60        "filter",
61        "name",
62        "response_conditions",
63    ];
64
65    let Some(mapping) = config.as_mapping() else {
66        return config.clone();
67    };
68
69    let filtered = mapping
70        .iter()
71        .filter(|(k, _)| !k.as_str().is_some_and(|key| STRUCTURAL.contains(&key)))
72        .map(|(k, v)| (k.clone(), v.clone()))
73        .collect();
74
75    serde_yaml::Value::Mapping(filtered)
76}
77
78// -----------------------------------------------------------------------------
79// Filter Factory Types
80// -----------------------------------------------------------------------------
81
82/// Factory function for creating HTTP filters from config.
83pub type HttpFilterFactory = Arc<dyn Fn(&serde_yaml::Value) -> Result<Box<dyn HttpFilter>, FilterError> + Send + Sync>;
84
85/// Factory function for creating TCP filters from config.
86pub type TcpFilterFactory = Arc<dyn Fn(&serde_yaml::Value) -> Result<Box<dyn TcpFilter>, FilterError> + Send + Sync>;
87
88// -----------------------------------------------------------------------------
89// FilterFactory
90// -----------------------------------------------------------------------------
91
92/// A protocol-tagged filter factory.
93pub enum FilterFactory {
94    /// Factory for HTTP-level filters.
95    Http(HttpFilterFactory),
96
97    /// Factory for TCP-level filters.
98    Tcp(TcpFilterFactory),
99}
100
101impl FilterFactory {
102    /// Create a filter from YAML config.
103    pub(crate) fn create(&self, config: &serde_yaml::Value) -> Result<AnyFilter, FilterError> {
104        match self {
105            Self::Http(f) => Ok(AnyFilter::Http(f(config)?)),
106            Self::Tcp(f) => Ok(AnyFilter::Tcp(f(config)?)),
107        }
108    }
109}
110
111// -----------------------------------------------------------------------------
112// Convenience Constructors
113// -----------------------------------------------------------------------------
114
115/// Wrap a builtin HTTP filter factory function.
116///
117/// ```
118/// use praxis_filter::{FilterError, FilterFactory, HttpFilter, http_builtin};
119///
120/// fn my_factory(_: &serde_yaml::Value) -> Result<Box<dyn HttpFilter>, FilterError> {
121///     unimplemented!()
122/// }
123///
124/// let _factory: FilterFactory = http_builtin(my_factory);
125/// ```
126#[expect(clippy::type_complexity, reason = "complex function pointer")]
127pub fn http_builtin(f: fn(&serde_yaml::Value) -> Result<Box<dyn HttpFilter>, FilterError>) -> FilterFactory {
128    FilterFactory::Http(Arc::new(f))
129}
130
131/// Wrap a builtin TCP filter factory function.
132///
133/// ```
134/// use praxis_filter::{FilterError, FilterFactory, TcpFilter, tcp_builtin};
135///
136/// fn my_factory(_: &serde_yaml::Value) -> Result<Box<dyn TcpFilter>, FilterError> {
137///     unimplemented!()
138/// }
139///
140/// let _factory: FilterFactory = tcp_builtin(my_factory);
141/// ```
142#[expect(clippy::type_complexity, reason = "complex function pointer")]
143pub fn tcp_builtin(f: fn(&serde_yaml::Value) -> Result<Box<dyn TcpFilter>, FilterError>) -> FilterFactory {
144    FilterFactory::Tcp(Arc::new(f))
145}
146
147// -----------------------------------------------------------------------------
148// Tests
149// -----------------------------------------------------------------------------
150
151#[cfg(test)]
152#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
153#[allow(
154    clippy::unwrap_used,
155    clippy::expect_used,
156    clippy::indexing_slicing,
157    clippy::panic,
158    clippy::unnecessary_wraps,
159    reason = "tests"
160)]
161mod tests {
162    use async_trait::async_trait;
163
164    use super::*;
165    use crate::{actions::FilterAction, context::HttpFilterContext};
166
167    #[test]
168    fn http_builtin_creates_http_variant() {
169        fn make(_: &serde_yaml::Value) -> Result<Box<dyn HttpFilter>, FilterError> {
170            Ok(Box::new(MinimalFilter))
171        }
172
173        let factory = http_builtin(make);
174        let filter = factory.create(&serde_yaml::Value::Null).unwrap();
175
176        assert_eq!(filter.name(), "minimal");
177        assert!(matches!(filter, AnyFilter::Http(_)));
178    }
179
180    #[test]
181    fn tcp_builtin_creates_tcp_variant() {
182        fn make(_: &serde_yaml::Value) -> Result<Box<dyn TcpFilter>, FilterError> {
183            Ok(Box::new(MinimalTcpFilter))
184        }
185
186        let factory = tcp_builtin(make);
187        let filter = factory.create(&serde_yaml::Value::Null).unwrap();
188
189        assert_eq!(filter.name(), "minimal_tcp");
190        assert!(matches!(filter, AnyFilter::Tcp(_)));
191    }
192
193    #[test]
194    fn strip_structural_keys_removes_known_keys() {
195        let mut mapping = serde_yaml::Mapping::new();
196        mapping.insert("filter".into(), "router".into());
197        mapping.insert("conditions".into(), serde_yaml::Value::Sequence(vec![]));
198        mapping.insert("name".into(), "my_filter".into());
199        mapping.insert("my_config_field".into(), "value".into());
200
201        let cleaned = strip_structural_keys(&serde_yaml::Value::Mapping(mapping));
202
203        let result = cleaned.as_mapping().expect("should be mapping");
204        assert!(
205            result.get("filter").is_none(),
206            "structural key 'filter' should be stripped"
207        );
208        assert!(
209            result.get("conditions").is_none(),
210            "structural key 'conditions' should be stripped"
211        );
212        assert!(result.get("name").is_none(), "structural key 'name' should be stripped");
213        assert_eq!(
214            result.get("my_config_field").and_then(|v| v.as_str()),
215            Some("value"),
216            "non-structural key should be preserved"
217        );
218    }
219
220    #[test]
221    fn strip_structural_keys_non_mapping_passes_through() {
222        let input = serde_yaml::Value::String("hello".to_owned());
223        let output = strip_structural_keys(&input);
224        assert_eq!(
225            output.as_str(),
226            Some("hello"),
227            "non-mapping value should pass through unchanged"
228        );
229    }
230
231    #[test]
232    fn strip_structural_keys_only_structural_produces_empty_mapping() {
233        let mut mapping = serde_yaml::Mapping::new();
234        mapping.insert("filter".into(), "router".into());
235        mapping.insert("conditions".into(), serde_yaml::Value::Null);
236        mapping.insert("name".into(), "x".into());
237        mapping.insert("failure_mode".into(), "open".into());
238        mapping.insert("response_conditions".into(), serde_yaml::Value::Null);
239        mapping.insert("branch_chains".into(), serde_yaml::Value::Null);
240
241        let cleaned = strip_structural_keys(&serde_yaml::Value::Mapping(mapping));
242
243        let result = cleaned.as_mapping().expect("should be mapping");
244        assert!(
245            result.is_empty(),
246            "mapping with only structural keys should be empty after stripping"
247        );
248    }
249
250    // -------------------------------------------------------------------------
251    // Test Utilities
252    // -------------------------------------------------------------------------
253
254    /// Minimal HTTP filter for factory tests.
255    struct MinimalFilter;
256
257    #[async_trait]
258    impl HttpFilter for MinimalFilter {
259        fn name(&self) -> &'static str {
260            "minimal"
261        }
262
263        async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
264            Ok(FilterAction::Continue)
265        }
266    }
267
268    /// Minimal TCP filter for factory tests.
269    struct MinimalTcpFilter;
270
271    #[async_trait]
272    impl TcpFilter for MinimalTcpFilter {
273        fn name(&self) -> &'static str {
274            "minimal_tcp"
275        }
276    }
277}