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}