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}