Skip to main content

praxis_filter/
lib.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4#![deny(unreachable_pub)]
5
6//! Filter pipeline engine for Praxis.
7
8mod actions;
9mod any_filter;
10pub mod body;
11pub mod builtins;
12mod condition;
13mod context;
14mod extensions;
15mod factory;
16mod filter;
17pub(crate) mod load_balancing;
18pub(crate) mod path_match;
19mod pipeline;
20mod registry;
21mod results;
22mod tcp_filter;
23
24pub use actions::{FilterAction, Rejection};
25pub use any_filter::AnyFilter;
26pub use body::{BodyAccess, BodyBuffer, BodyBufferOverflow, BodyCapabilities, BodyMode};
27#[cfg(feature = "cpex-policy-engine")]
28pub use builtins::PolicyFilter;
29pub use builtins::{
30    CircuitBreakerFilter, ContainsValue, CredentialInjectionFilter, DisallowedOriginMode, GuardrailsAction,
31    GuardrailsFilter, LoadBalancerFilter, PiiKind, RateLimitMode, RedirectStatus, RouterFilter, RuleTargetKind,
32    has_dot_dot_traversal, http::payload_processing::compression_config::CompressionConfig, normalize_rewritten_path,
33};
34pub use condition::{should_execute, should_execute_response, should_execute_response_ref};
35pub use context::{HttpFilterContext, Request, Response};
36pub use extensions::RequestExtensions;
37pub use factory::{FilterFactory, HttpFilterFactory, TcpFilterFactory, http_builtin, parse_filter_config, tcp_builtin};
38pub use filter::{Filter, FilterContext, FilterError, HttpFilter};
39pub use pipeline::{FilterPipeline, PipelineExtension};
40pub use praxis_core::config::{FailureMode, FilterEntry};
41pub use registry::FilterRegistry;
42pub use results::FilterResultSet;
43pub use tcp_filter::{TcpFilter, TcpFilterContext};
44
45// -----------------------------------------------------------------------------
46// Custom Filter Registration
47// -----------------------------------------------------------------------------
48
49/// Macro for registering custom filters alongside built-ins.
50///
51/// ```ignore
52/// use praxis_filter::register_filters;
53///
54/// pub struct MyAuthFilter { /* ... */ }
55/// pub struct MyTcpLogger { /* ... */ }
56///
57/// register_filters! {
58///     http "my_auth" => MyAuthFilter::from_config,
59///     tcp  "my_tcp_logger" => MyTcpLogger::from_config,
60/// }
61/// ```
62#[macro_export]
63macro_rules! register_filters {
64    ( @register $registry:ident, http $name:expr => $factory:expr ) => {
65        $registry.register(
66            $name,
67            $crate::FilterFactory::Http(
68                ::std::sync::Arc::new(move |config: &serde_yaml::Value| {
69                    ($factory)(config)
70                }),
71            ),
72        ).unwrap_or_else(|_| panic!("duplicate filter name: '{}'", $name));
73    };
74    ( @register $registry:ident, tcp $name:expr => $factory:expr ) => {
75        $registry.register(
76            $name,
77            $crate::FilterFactory::Tcp(
78                ::std::sync::Arc::new(move |config: &serde_yaml::Value| {
79                    ($factory)(config)
80                }),
81            ),
82        ).unwrap_or_else(|_| panic!("duplicate filter name: '{}'", $name));
83    };
84    ( $( $kind:ident $name:expr => $factory:expr ),* $(,)? ) => {
85        /// Build a custom filter registry with builtins and user-registered filters.
86        pub fn custom_registry() -> $crate::FilterRegistry {
87            let mut registry = $crate::FilterRegistry::with_builtins();
88            $(
89                $crate::register_filters!(@register registry, $kind $name => $factory);
90            )*
91            registry
92        }
93    };
94}
95
96// -----------------------------------------------------------------------------
97// External Filter Export
98// -----------------------------------------------------------------------------
99
100/// Macro for exporting filters from an external crate for build-time
101/// auto-discovery.
102///
103/// External filter crates use this macro to declare which filters they
104/// provide. The generated `register_filters` function is called
105/// automatically by the Praxis server when the crate is listed as a
106/// dependency with a `[package.metadata.praxis-filters]` marker in
107/// its `Cargo.toml`.
108///
109/// ```ignore
110/// use praxis_filter::export_filters;
111///
112/// export_filters! {
113///     http "my_auth" => MyAuthFilter::from_config,
114///     tcp  "my_tcp_logger" => MyTcpLogger::from_config,
115/// }
116/// ```
117///
118/// The external crate's `Cargo.toml` must also include:
119///
120/// ```toml
121/// [package.metadata.praxis-filters]
122/// ```
123///
124/// With these two pieces in place, adding the crate as a dependency
125/// to the Praxis server is sufficient to make the filters available
126/// in YAML configuration.
127#[macro_export]
128macro_rules! export_filters {
129    ( $( $kind:ident $name:expr => $factory:expr ),* $(,)? ) => {
130        /// Register this crate's filters into a Praxis [`FilterRegistry`].
131        ///
132        /// Called automatically by the Praxis build-time filter discovery
133        /// system. Can also be called manually for testing or custom
134        /// server builds.
135        ///
136        /// # Panics
137        ///
138        /// Panics if any filter name collides with an already-registered
139        /// filter (built-in or from another external crate).
140        ///
141        /// [`FilterRegistry`]: $crate::FilterRegistry
142        pub fn register_filters(registry: &mut $crate::FilterRegistry) {
143            $(
144                $crate::register_filters!(@register registry, $kind $name => $factory);
145            )*
146        }
147    };
148}
149
150// -----------------------------------------------------------------------------
151// Macro Tests
152// -----------------------------------------------------------------------------
153
154#[cfg(test)]
155#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
156#[allow(
157    unreachable_pub,
158    clippy::unwrap_used,
159    clippy::expect_used,
160    clippy::panic,
161    clippy::unnecessary_wraps,
162    reason = "internal pub items re-exported selectively; test module"
163)]
164mod macro_tests {
165    use async_trait::async_trait;
166
167    use crate::{FilterAction, FilterError, HttpFilter, HttpFilterContext, TcpFilter};
168
169    register_filters! {
170        http "dummy_http" => DummyHttpFilter::from_config,
171        tcp  "dummy_tcp"  => DummyTcpFilter::from_config,
172    }
173
174    #[test]
175    fn macro_registers_http_filter() {
176        let registry = custom_registry();
177        assert!(
178            registry.available_filters().contains(&"dummy_http"),
179            "registry should contain custom HTTP filter"
180        );
181    }
182
183    #[test]
184    fn macro_registers_tcp_filter() {
185        let registry = custom_registry();
186        assert!(
187            registry.available_filters().contains(&"dummy_tcp"),
188            "registry should contain custom TCP filter"
189        );
190    }
191
192    #[test]
193    fn macro_registers_http_filter_with_name_expression() {
194        let mut registry = crate::FilterRegistry::with_builtins();
195        let name = String::from("dummy_http_expr");
196        register_filters!(@register registry, http name.as_str() => DummyHttpFilter::from_config);
197        assert!(
198            registry.available_filters().contains(&"dummy_http_expr"),
199            "registry should contain custom HTTP filter registered with a name expression"
200        );
201    }
202
203    #[test]
204    fn macro_registers_tcp_filter_with_name_expression() {
205        let mut registry = crate::FilterRegistry::with_builtins();
206        let name = String::from("dummy_tcp_expr");
207        register_filters!(@register registry, tcp name.as_str() => DummyTcpFilter::from_config);
208        assert!(
209            registry.available_filters().contains(&"dummy_tcp_expr"),
210            "registry should contain custom TCP filter registered with a name expression"
211        );
212    }
213
214    #[test]
215    fn macro_preserves_builtins() {
216        let registry = custom_registry();
217        assert!(
218            registry.available_filters().contains(&"router"),
219            "registry should still contain built-in router"
220        );
221        assert!(
222            registry.available_filters().contains(&"load_balancer"),
223            "registry should still contain built-in load_balancer"
224        );
225    }
226
227    #[test]
228    fn macro_registered_http_filter_creates_successfully() {
229        let registry = custom_registry();
230        let result = registry.create("dummy_http", &serde_yaml::Value::Null);
231        assert!(result.is_ok(), "custom HTTP filter should instantiate without error");
232    }
233
234    #[test]
235    fn macro_registered_tcp_filter_creates_successfully() {
236        let registry = custom_registry();
237        let result = registry.create("dummy_tcp", &serde_yaml::Value::Null);
238        assert!(result.is_ok(), "custom TCP filter should instantiate without error");
239    }
240
241    #[test]
242    #[should_panic(expected = "duplicate filter name: 'router'")]
243    fn macro_panics_on_builtin_collision() {
244        let mut registry = crate::FilterRegistry::with_builtins();
245        register_filters!(@register registry, http "router" => DummyHttpFilter::from_config);
246    }
247
248    // -------------------------------------------------------------------------
249    // export_filters! tests
250    // -------------------------------------------------------------------------
251
252    mod export_test {
253        use super::*;
254
255        export_filters! {
256            http "exported_http" => DummyHttpFilter::from_config,
257            tcp  "exported_tcp"  => DummyTcpFilter::from_config,
258        }
259    }
260
261    #[test]
262    fn export_filters_registers_http() {
263        let mut registry = crate::FilterRegistry::with_builtins();
264        export_test::register_filters(&mut registry);
265        assert!(
266            registry.available_filters().contains(&"exported_http"),
267            "exported HTTP filter should be registered"
268        );
269    }
270
271    #[test]
272    fn export_filters_registers_tcp() {
273        let mut registry = crate::FilterRegistry::with_builtins();
274        export_test::register_filters(&mut registry);
275        assert!(
276            registry.available_filters().contains(&"exported_tcp"),
277            "exported TCP filter should be registered"
278        );
279    }
280
281    #[test]
282    fn export_filters_preserves_builtins() {
283        let mut registry = crate::FilterRegistry::with_builtins();
284        export_test::register_filters(&mut registry);
285        assert!(
286            registry.available_filters().contains(&"router"),
287            "built-in router should still be registered"
288        );
289    }
290
291    #[test]
292    fn export_filters_creates_http_filter_successfully() {
293        let mut registry = crate::FilterRegistry::with_builtins();
294        export_test::register_filters(&mut registry);
295        let result = registry.create("exported_http", &serde_yaml::Value::Null);
296        assert!(result.is_ok(), "exported HTTP filter should instantiate without error");
297    }
298
299    #[test]
300    fn export_filters_creates_tcp_filter_successfully() {
301        let mut registry = crate::FilterRegistry::with_builtins();
302        export_test::register_filters(&mut registry);
303        let result = registry.create("exported_tcp", &serde_yaml::Value::Null);
304        assert!(result.is_ok(), "exported TCP filter should instantiate without error");
305    }
306
307    #[test]
308    #[should_panic(expected = "duplicate filter name: 'router'")]
309    fn export_filters_panics_on_builtin_collision() {
310        mod collision {
311            use super::*;
312
313            export_filters! {
314                http "router" => DummyHttpFilter::from_config,
315            }
316        }
317        let mut registry = crate::FilterRegistry::with_builtins();
318        collision::register_filters(&mut registry);
319    }
320
321    // -----------------------------------------------------------------------------
322    // Test Utilities
323    // -----------------------------------------------------------------------------
324
325    /// Dummy HTTP filter for macro testing.
326    struct DummyHttpFilter;
327
328    #[async_trait]
329    impl HttpFilter for DummyHttpFilter {
330        fn name(&self) -> &'static str {
331            "dummy_http"
332        }
333
334        async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
335            Ok(FilterAction::Continue)
336        }
337    }
338
339    impl DummyHttpFilter {
340        fn from_config(_: &serde_yaml::Value) -> Result<Box<dyn HttpFilter>, FilterError> {
341            Ok(Box::new(Self))
342        }
343    }
344
345    /// Dummy TCP filter for macro testing.
346    struct DummyTcpFilter;
347
348    #[async_trait]
349    impl TcpFilter for DummyTcpFilter {
350        fn name(&self) -> &'static str {
351            "dummy_tcp"
352        }
353    }
354
355    impl DummyTcpFilter {
356        fn from_config(_: &serde_yaml::Value) -> Result<Box<dyn TcpFilter>, FilterError> {
357            Ok(Box::new(Self))
358        }
359    }
360}
361
362// -----------------------------------------------------------------------------
363// Test Utilities
364// -----------------------------------------------------------------------------
365
366#[cfg(test)]
367#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
368#[allow(clippy::expect_used, reason = "test utilities")]
369pub(crate) mod test_utils {
370    use std::sync::LazyLock;
371
372    use http::{HeaderMap, Method, Uri};
373    use praxis_core::id::IdGenerator;
374
375    use crate::{HttpFilterContext, Request};
376
377    /// Deterministic ID generator for tests (seed=0).
378    static TEST_ID_GENERATOR: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::with_seed(0));
379
380    pub(crate) fn make_request(method: Method, path: &str) -> Request {
381        Request {
382            method,
383            uri: path.parse::<Uri>().expect("invalid URI in test"),
384            headers: HeaderMap::new(),
385        }
386    }
387
388    #[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
389    #[allow(
390        clippy::too_many_lines,
391        reason = "test context constructor mirrors all context fields"
392    )]
393    pub(crate) fn make_filter_context(req: &Request) -> HttpFilterContext<'_> {
394        HttpFilterContext {
395            body_done_indices: Vec::new(),
396            branch_iterations: std::collections::HashMap::new(),
397            client_addr: None,
398            cluster: None,
399            current_filter_id: None,
400            downstream_tls: false,
401            extensions: crate::extensions::RequestExtensions::default(),
402            executed_filter_indices: Vec::new(),
403            extra_request_headers: Vec::new(),
404            request_headers_to_remove: Vec::new(),
405            request_headers_to_set: Vec::new(),
406            filter_metadata: std::collections::HashMap::new(),
407            filter_results: std::collections::HashMap::new(),
408            filter_state: std::collections::HashMap::new(),
409            health_registry: None,
410            id_generator: &TEST_ID_GENERATOR,
411            kv_stores: None,
412            request: req,
413            request_body_bytes: 0,
414            request_body_mode: crate::body::BodyMode::Stream,
415            request_start: std::time::Instant::now(),
416            response_body_bytes: 0,
417            response_body_mode: crate::body::BodyMode::Stream,
418            response_header: None,
419            response_headers_modified: false,
420            rewritten_path: None,
421            selected_endpoint_index: None,
422            time_source: &praxis_core::time::SystemTimeSource,
423            upstream: None,
424        }
425    }
426
427    /// Build a minimal OK response for filter unit tests.
428    pub(crate) fn make_response() -> crate::context::Response {
429        crate::context::Response {
430            headers: HeaderMap::new(),
431            status: http::StatusCode::OK,
432        }
433    }
434}