Skip to main content

praxis_filter/
actions.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Filter return types: continue processing or reject with a response.
5
6use bytes::Bytes;
7
8// -----------------------------------------------------------------------------
9// FilterAction
10// -----------------------------------------------------------------------------
11
12/// Result of a filter's request or response processing.
13///
14/// ```
15/// use praxis_filter::{FilterAction, Rejection};
16///
17/// let action = FilterAction::Continue;
18/// assert!(matches!(action, FilterAction::Continue));
19///
20/// let reject = FilterAction::Reject(Rejection::status(403));
21/// assert!(matches!(reject, FilterAction::Reject(r) if r.status == 403));
22///
23/// let release = FilterAction::Release;
24/// assert!(matches!(release, FilterAction::Release));
25///
26/// let body_done = FilterAction::BodyDone;
27/// assert!(matches!(body_done, FilterAction::BodyDone));
28/// ```
29#[derive(Debug)]
30#[must_use]
31pub enum FilterAction {
32    /// Continue to the next filter in the pipeline.
33    Continue,
34
35    /// Stop processing and respond with the given rejection.
36    Reject(Rejection),
37
38    /// Signal that accumulated body data ([`StreamBuffer`] mode)
39    /// should be forwarded to upstream. After release, remaining
40    /// chunks flow through in stream mode.
41    ///
42    /// In non-StreamBuffer contexts (including the TCP pipeline),
43    /// behaves as [`Continue`].
44    ///
45    /// [`StreamBuffer`]: crate::BodyMode::StreamBuffer
46    /// [`Continue`]: FilterAction::Continue
47    Release,
48
49    /// Skip this filter for remaining body chunks.
50    ///
51    /// The filter has completed its body inspection and does
52    /// not need to see further chunks. The pipeline continues
53    /// calling other body filters; only this filter is skipped.
54    ///
55    /// In non-body contexts (request and response phases),
56    /// behaves as [`Continue`].
57    ///
58    /// [`Continue`]: FilterAction::Continue
59    BodyDone,
60}
61
62// -----------------------------------------------------------------------------
63// Rejection
64// -----------------------------------------------------------------------------
65
66/// A filter rejection response.
67///
68/// ```
69/// use praxis_filter::Rejection;
70///
71/// // Simple status-only rejection:
72/// let r = Rejection::status(403);
73/// assert_eq!(r.status, 403);
74/// assert!(r.headers.is_empty());
75/// assert!(r.body.is_none());
76///
77/// // Rich rejection with headers and body:
78/// let r = Rejection::status(429)
79///     .with_header("Retry-After", "60")
80///     .with_body(b"rate limit exceeded".as_slice());
81/// assert_eq!(r.status, 429);
82/// assert_eq!(r.headers.len(), 1);
83/// assert!(r.body.is_some());
84/// ```
85#[derive(Debug)]
86#[must_use]
87pub struct Rejection {
88    /// Response body.
89    pub body: Option<Bytes>,
90
91    /// Response headers.
92    pub headers: Vec<(String, String)>,
93
94    /// HTTP status code.
95    pub status: u16,
96}
97
98impl Rejection {
99    /// Create a rejection with the given status code.
100    ///
101    /// # Panics
102    ///
103    /// Panics if `code` is outside the valid HTTP status range
104    /// (100..=599).
105    pub fn status(code: u16) -> Self {
106        assert!(
107            (100..=599).contains(&code),
108            "HTTP status code must be 100..=599, got {code}"
109        );
110        Self {
111            status: code,
112            headers: Vec::new(),
113            body: None,
114        }
115    }
116
117    /// Set the body of the rejection response.
118    pub fn with_body(mut self, body: impl Into<Bytes>) -> Self {
119        self.body = Some(body.into());
120        self
121    }
122
123    /// Add a header to the rejection response.
124    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
125        self.headers.push((name.into(), value.into()));
126        self
127    }
128}
129
130// -----------------------------------------------------------------------------
131// Tests
132// -----------------------------------------------------------------------------
133
134#[cfg(test)]
135#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
136#[allow(
137    clippy::unwrap_used,
138    clippy::expect_used,
139    clippy::indexing_slicing,
140    clippy::panic,
141    reason = "tests"
142)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn rejection_status_defaults() {
148        let r = Rejection::status(404);
149        assert_eq!(r.status, 404, "status should match constructor arg");
150        assert!(r.headers.is_empty(), "headers should default to empty");
151        assert!(r.body.is_none(), "body should default to None");
152    }
153
154    #[test]
155    fn rejection_status_boundary_100() {
156        let r = Rejection::status(100);
157        assert_eq!(r.status, 100, "100 is a valid HTTP status");
158    }
159
160    #[test]
161    fn rejection_status_boundary_599() {
162        let r = Rejection::status(599);
163        assert_eq!(r.status, 599, "599 is a valid HTTP status");
164    }
165
166    #[test]
167    #[should_panic(expected = "HTTP status code must be 100..=599")]
168    fn rejection_status_zero_panics() {
169        let _r = Rejection::status(0);
170    }
171
172    #[test]
173    #[should_panic(expected = "HTTP status code must be 100..=599")]
174    fn rejection_status_600_panics() {
175        let _r = Rejection::status(600);
176    }
177
178    #[test]
179    fn rejection_with_header_appends() {
180        let r = Rejection::status(403)
181            .with_header("X-Reason", "forbidden")
182            .with_header("X-Request-Id", "abc");
183        assert_eq!(r.headers.len(), 2, "should have two appended headers");
184        assert_eq!(
185            r.headers[0],
186            ("X-Reason".into(), "forbidden".into()),
187            "first header should match"
188        );
189        assert_eq!(
190            r.headers[1],
191            ("X-Request-Id".into(), "abc".into()),
192            "second header should match"
193        );
194    }
195
196    #[test]
197    fn rejection_with_body_sets_bytes() {
198        let r = Rejection::status(400).with_body(b"bad request".as_slice());
199        assert_eq!(
200            r.body.unwrap(),
201            Bytes::from_static(b"bad request"),
202            "body should contain provided bytes"
203        );
204    }
205
206    #[test]
207    fn filter_action_continue_variant() {
208        assert!(
209            matches!(FilterAction::Continue, FilterAction::Continue),
210            "Continue should match Continue"
211        );
212    }
213
214    #[test]
215    fn filter_action_reject_carries_rejection() {
216        let action = FilterAction::Reject(Rejection::status(503));
217        assert!(
218            matches!(action, FilterAction::Reject(r) if r.status == 503),
219            "Reject should carry rejection with status 503"
220        );
221    }
222
223    #[test]
224    fn filter_action_release_variant() {
225        assert!(
226            matches!(FilterAction::Release, FilterAction::Release),
227            "Release should match Release"
228        );
229    }
230
231    #[test]
232    fn filter_action_body_done_variant() {
233        assert!(
234            matches!(FilterAction::BodyDone, FilterAction::BodyDone),
235            "BodyDone should match BodyDone"
236        );
237    }
238}