Skip to main content

praxis_filter/
filter.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! The [`HttpFilter`] trait definition.
5//!
6//! Every HTTP filter implements this trait.
7
8use async_trait::async_trait;
9use bytes::Bytes;
10use praxis_core::config::InsecureOptions;
11
12pub(crate) use crate::context::HttpFilterContext;
13use crate::{
14    actions::FilterAction,
15    body::{BodyAccess, BodyMode},
16    builtins::http::payload_processing::compression_config::CompressionConfig,
17};
18
19// -----------------------------------------------------------------------------
20// Backward-compatible Aliases
21// -----------------------------------------------------------------------------
22
23/// Backward-compatible alias for [`HttpFilter`].
24pub type Filter = dyn HttpFilter;
25
26/// Backward-compatible alias for [`HttpFilterContext`].
27///
28/// [`HttpFilterContext`]: crate::context::HttpFilterContext
29pub type FilterContext<'a> = HttpFilterContext<'a>;
30
31// -----------------------------------------------------------------------------
32// HttpFilter Trait
33// -----------------------------------------------------------------------------
34
35/// A filter that participates in HTTP request/response processing.
36///
37/// # Example
38///
39/// ```
40/// use async_trait::async_trait;
41/// use praxis_filter::{FilterAction, FilterError, HttpFilter, HttpFilterContext};
42///
43/// struct NoopFilter;
44///
45/// #[async_trait]
46/// impl HttpFilter for NoopFilter {
47///     fn name(&self) -> &'static str {
48///         "noop"
49///     }
50///
51///     async fn on_request(
52///         &self,
53///         _ctx: &mut HttpFilterContext<'_>,
54///     ) -> Result<FilterAction, FilterError> {
55///         Ok(FilterAction::Continue)
56///     }
57/// }
58///
59/// let filter = NoopFilter;
60/// assert_eq!(filter.name(), "noop");
61/// ```
62#[async_trait]
63pub trait HttpFilter: Send + Sync {
64    /// Unique name identifying this filter type (e.g. `"router"`, `"rate_limit"`).
65    fn name(&self) -> &'static str;
66
67    /// Called for each incoming request, in pipeline order.
68    async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError>;
69
70    /// Called for each response, in reverse pipeline order.
71    ///
72    /// Default: [`FilterAction::Continue`]
73    async fn on_response(&self, ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
74        let _ = ctx;
75        Ok(FilterAction::Continue)
76    }
77
78    // -------------------------------------------------------------------------
79    // Body Access Declarations
80    // -------------------------------------------------------------------------
81
82    /// Declares what access this filter needs to request bodies.
83    ///
84    /// Return [`BodyAccess::None`] (the default) when the filter
85    /// does not inspect or modify request bodies. Return
86    /// [`BodyAccess::ReadOnly`] to receive body chunks in
87    /// [`on_request_body`] without modification rights, or
88    /// [`BodyAccess::ReadWrite`] to mutate body bytes in place.
89    ///
90    /// [`on_request_body`]: HttpFilter::on_request_body
91    fn request_body_access(&self) -> BodyAccess {
92        BodyAccess::None
93    }
94
95    /// Declares what access this filter needs to response bodies.
96    ///
97    /// Return [`BodyAccess::None`] (the default) when the filter
98    /// does not inspect or modify response bodies. Return
99    /// [`BodyAccess::ReadOnly`] to receive body chunks in
100    /// [`on_response_body`] without modification rights, or
101    /// [`BodyAccess::ReadWrite`] to mutate body bytes in place.
102    ///
103    /// [`on_response_body`]: HttpFilter::on_response_body
104    fn response_body_access(&self) -> BodyAccess {
105        BodyAccess::None
106    }
107
108    /// Declares the delivery mode for request body chunks.
109    ///
110    /// [`BodyMode::Stream`] (the default) delivers chunks as they
111    /// arrive with minimal latency. [`BodyMode::StreamBuffer`]
112    /// accumulates chunks and defers forwarding until the filter
113    /// calls [`FilterAction::Release`] or end-of-stream; use this
114    /// when the filter needs the full body before making a decision
115    /// (e.g. JSON parsing for routing).
116    ///
117    /// [`FilterAction::Release`]: crate::FilterAction::Release
118    fn request_body_mode(&self) -> BodyMode {
119        BodyMode::Stream
120    }
121
122    /// Declares the delivery mode for response body chunks.
123    ///
124    /// [`BodyMode::Stream`] (the default) delivers chunks as they
125    /// arrive with minimal latency. [`BodyMode::StreamBuffer`]
126    /// accumulates chunks and defers forwarding until the filter
127    /// calls [`FilterAction::Release`] or end-of-stream; use this
128    /// when the filter needs the complete response body (e.g.
129    /// response persistence).
130    ///
131    /// [`FilterAction::Release`]: crate::FilterAction::Release
132    fn response_body_mode(&self) -> BodyMode {
133        BodyMode::Stream
134    }
135
136    /// Whether this filter needs the original request context
137    /// during body phases.
138    ///
139    /// When `true`, the pipeline preserves the original request
140    /// headers and URI so they are available in [`on_request_body`]
141    /// and [`on_response_body`]. Enable this when body-phase
142    /// decisions depend on request metadata (method, path, headers).
143    ///
144    /// [`on_request_body`]: HttpFilter::on_request_body
145    /// [`on_response_body`]: HttpFilter::on_response_body
146    fn needs_request_context(&self) -> bool {
147        false
148    }
149
150    /// Apply global [`InsecureOptions`] to this filter.
151    ///
152    /// Filters that support insecure overrides (e.g. CSRF
153    /// log-only mode) override this. Default: no-op.
154    ///
155    /// [`InsecureOptions`]: praxis_core::config::InsecureOptions
156    fn apply_insecure_options(&self, _options: &InsecureOptions) {}
157
158    /// Returns the compression configuration if this filter enables
159    /// response compression.
160    ///
161    /// Return `Some` to activate response compression with the
162    /// given algorithm and level settings. Return `None` (the
163    /// default) to leave compression unmodified. Only one filter
164    /// in a pipeline should return `Some`.
165    fn compression_config(&self) -> Option<&CompressionConfig> {
166        None
167    }
168
169    // -------------------------------------------------------------------------
170    // Body Hooks
171    // -------------------------------------------------------------------------
172
173    /// Called for each chunk of request body data, in pipeline order.
174    ///
175    /// `body` contains the current chunk (`None` when empty).
176    /// `end_of_stream` is `true` on the final chunk. Filters may
177    /// safely modify `body` before `end_of_stream` when using
178    /// [`BodyAccess::ReadWrite`]; buffered modes guarantee all
179    /// bytes arrive in a single call with `end_of_stream = true`.
180    /// Return [`FilterAction::Reject`] to abort with an error
181    /// response.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`FilterError`] if body processing fails. The
186    /// pipeline converts errors into 500 responses.
187    ///
188    /// [`FilterAction::Reject`]: crate::FilterAction::Reject
189    async fn on_request_body(
190        &self,
191        ctx: &mut HttpFilterContext<'_>,
192        body: &mut Option<Bytes>,
193        end_of_stream: bool,
194    ) -> Result<FilterAction, FilterError> {
195        let _ = (ctx, body, end_of_stream);
196        Ok(FilterAction::Continue)
197    }
198
199    /// Called for each chunk of response body data, in reverse
200    /// pipeline order.
201    ///
202    /// `body` contains the current chunk (`None` when empty).
203    /// `end_of_stream` is `true` on the final chunk. Filters may
204    /// safely modify `body` before `end_of_stream` when using
205    /// [`BodyAccess::ReadWrite`]; buffered modes guarantee all
206    /// bytes arrive in a single call with `end_of_stream = true`.
207    /// Return [`FilterAction::Reject`] to abort with an error
208    /// response.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`FilterError`] if body processing fails. The
213    /// pipeline converts errors into 502 responses.
214    ///
215    /// [`FilterAction::Reject`]: crate::FilterAction::Reject
216    fn on_response_body(
217        &self,
218        ctx: &mut HttpFilterContext<'_>,
219        body: &mut Option<Bytes>,
220        end_of_stream: bool,
221    ) -> Result<FilterAction, FilterError> {
222        let _ = (ctx, body, end_of_stream);
223        Ok(FilterAction::Continue)
224    }
225}
226
227/// Boxed error type for filter results.
228pub type FilterError = Box<dyn std::error::Error + Send + Sync>;
229
230// -----------------------------------------------------------------------------
231// Tests
232// -----------------------------------------------------------------------------
233
234#[cfg(test)]
235#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
236#[allow(
237    clippy::unwrap_used,
238    clippy::expect_used,
239    clippy::indexing_slicing,
240    clippy::panic,
241    reason = "tests"
242)]
243mod tests {
244    use async_trait::async_trait;
245
246    use super::*;
247    use crate::{FilterAction, FilterError};
248
249    #[tokio::test]
250    async fn default_on_response_returns_continue() {
251        let filter = MinimalFilter;
252        let req = crate::test_utils::make_request(http::Method::GET, "/");
253        let mut ctx = crate::test_utils::make_filter_context(&req);
254
255        let action = filter.on_response(&mut ctx).await.unwrap();
256
257        assert!(
258            matches!(action, FilterAction::Continue),
259            "default on_response should return Continue"
260        );
261    }
262
263    #[test]
264    fn default_body_access_is_none() {
265        let filter = MinimalFilter;
266        assert_eq!(
267            filter.request_body_access(),
268            BodyAccess::None,
269            "default request body access should be None"
270        );
271        assert_eq!(
272            filter.response_body_access(),
273            BodyAccess::None,
274            "default response body access should be None"
275        );
276        assert_eq!(
277            filter.request_body_mode(),
278            BodyMode::Stream,
279            "default request body mode should be Stream"
280        );
281        assert_eq!(
282            filter.response_body_mode(),
283            BodyMode::Stream,
284            "default response body mode should be Stream"
285        );
286        assert!(
287            !filter.needs_request_context(),
288            "default needs_request_context should be false"
289        );
290    }
291
292    // -------------------------------------------------------------------------
293    // Test Utilities
294    // -------------------------------------------------------------------------
295
296    /// Minimal filter for verifying trait defaults.
297    struct MinimalFilter;
298
299    #[async_trait]
300    impl HttpFilter for MinimalFilter {
301        fn name(&self) -> &'static str {
302            "minimal"
303        }
304
305        async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
306            Ok(FilterAction::Continue)
307        }
308    }
309}