Skip to main content

eggserve_core/primitives/
response.rs

1//! Response planning data structures for static file serving.
2//!
3//! These value objects are independent of Hyper and can be consumed by Rust
4//! callers, Python adapters, or test assertions.
5
6/// A status code suitable for response planning.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub struct ResponseStatus(pub u16);
9
10impl ResponseStatus {
11    pub const OK: Self = Self(200);
12    pub const NOT_MODIFIED: Self = Self(304);
13    pub const PARTIAL_CONTENT: Self = Self(206);
14    pub const NOT_RANGE_SATISFIABLE: Self = Self(416);
15    pub const METHOD_NOT_ALLOWED: Self = Self(405);
16    pub const NOT_FOUND: Self = Self(404);
17    pub const FORBIDDEN: Self = Self(403);
18    pub const BAD_REQUEST: Self = Self(400);
19    pub const PAYLOAD_TOO_LARGE: Self = Self(413);
20    pub const INTERNAL_SERVER_ERROR: Self = Self(500);
21    pub const SERVICE_UNAVAILABLE: Self = Self(503);
22
23    pub fn as_u16(&self) -> u16 {
24        self.0
25    }
26}
27
28impl std::fmt::Display for ResponseStatus {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "{}", self.0)
31    }
32}
33
34/// A single response header as a name/value pair.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct ResponseHeader {
37    pub name: String,
38    pub value: String,
39}
40
41/// A collection of response headers.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct HeaderMapPlan {
44    headers: Vec<ResponseHeader>,
45}
46
47impl HeaderMapPlan {
48    pub fn new() -> Self {
49        Self {
50            headers: Vec::new(),
51        }
52    }
53
54    pub fn push(&mut self, name: impl Into<String>, value: impl Into<String>) {
55        self.headers.push(ResponseHeader {
56            name: name.into(),
57            value: value.into(),
58        });
59    }
60
61    pub fn get(&self, name: &str) -> Option<&str> {
62        self.headers
63            .iter()
64            .find(|h| h.name.eq_ignore_ascii_case(name))
65            .map(|h| h.value.as_str())
66    }
67
68    pub fn contains(&self, name: &str) -> bool {
69        self.headers
70            .iter()
71            .any(|h| h.name.eq_ignore_ascii_case(name))
72    }
73
74    pub fn iter(&self) -> impl Iterator<Item = &ResponseHeader> {
75        self.headers.iter()
76    }
77
78    pub fn len(&self) -> usize {
79        self.headers.len()
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.headers.is_empty()
84    }
85}
86
87/// A byte range within a file.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub struct FileRange {
90    pub start: u64,
91    pub end_inclusive: u64,
92}
93
94impl FileRange {
95    pub fn new(start: u64, end_inclusive: u64) -> Self {
96        Self {
97            start,
98            end_inclusive,
99        }
100    }
101
102    pub fn len(&self) -> u64 {
103        self.checked_len().expect("FileRange length overflow")
104    }
105
106    pub fn checked_len(&self) -> Option<u64> {
107        if self.is_empty() {
108            return Some(0);
109        }
110        self.end_inclusive
111            .checked_sub(self.start)
112            .and_then(|len| len.checked_add(1))
113    }
114
115    pub fn is_empty(&self) -> bool {
116        self.end_inclusive < self.start
117    }
118}
119
120/// Body plan for a response.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum BodyPlan {
123    Empty,
124    FullBytes(Vec<u8>),
125    FileFull,
126    FileRange { start: u64, end_inclusive: u64 },
127}
128
129/// A complete response plan that can be translated into any HTTP framework.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct StaticResponsePlan {
132    pub status: ResponseStatus,
133    pub headers: HeaderMapPlan,
134    pub body: BodyPlan,
135}
136
137impl StaticResponsePlan {
138    pub fn status_code(&self) -> u16 {
139        self.status.as_u16()
140    }
141}
142
143/// Outcome of evaluating conditional request headers.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum ConditionalRequestOutcome {
146    /// Conditional validators match; serve 304 Not Modified with validators.
147    NotModified(HeaderMapPlan),
148    /// Conditional validators do not match; serve full response.
149    FullResponse,
150    /// Conditional headers were malformed or unparseable; serve full response.
151    Malformed,
152}
153
154/// Outcome of evaluating range request headers.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum RangeRequestOutcome {
157    /// Valid single byte range; serve 206 Partial Content.
158    Satisfiable(FileRange),
159    /// Range cannot be satisfied; serve 416 Range Not Satisfiable.
160    NotSatisfiable,
161    /// Range syntax is malformed or unsupported; serve full 200 response.
162    MalformedOrUnsupported,
163    /// Multiple ranges provided; single-range only for now.
164    MultipleRanges,
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn status_code_values() {
173        assert_eq!(ResponseStatus::OK.as_u16(), 200);
174        assert_eq!(ResponseStatus::NOT_MODIFIED.as_u16(), 304);
175        assert_eq!(ResponseStatus::PARTIAL_CONTENT.as_u16(), 206);
176        assert_eq!(ResponseStatus::NOT_RANGE_SATISFIABLE.as_u16(), 416);
177    }
178
179    #[test]
180    fn status_display() {
181        assert_eq!(format!("{}", ResponseStatus::OK), "200");
182        assert_eq!(format!("{}", ResponseStatus::NOT_FOUND), "404");
183    }
184
185    #[test]
186    fn header_map_push_and_get() {
187        let mut headers = HeaderMapPlan::new();
188        headers.push("content-type", "text/plain");
189        headers.push("x-custom", "value");
190
191        assert_eq!(headers.get("content-type"), Some("text/plain"));
192        assert_eq!(headers.get("Content-Type"), Some("text/plain"));
193        assert_eq!(headers.get("x-custom"), Some("value"));
194        assert_eq!(headers.get("missing"), None);
195    }
196
197    #[test]
198    fn header_map_contains() {
199        let mut headers = HeaderMapPlan::new();
200        headers.push("etag", "W/\"123\"");
201
202        assert!(headers.contains("etag"));
203        assert!(headers.contains("ETag"));
204        assert!(!headers.contains("missing"));
205    }
206
207    #[test]
208    fn header_map_len() {
209        let mut headers = HeaderMapPlan::new();
210        assert!(headers.is_empty());
211        headers.push("a", "b");
212        assert_eq!(headers.len(), 1);
213    }
214
215    #[test]
216    fn file_range_len() {
217        let range = FileRange::new(0, 4);
218        assert_eq!(range.len(), 5);
219
220        let range = FileRange::new(10, 19);
221        assert_eq!(range.len(), 10);
222    }
223
224    #[test]
225    fn invalid_file_range_is_empty() {
226        let range = FileRange::new(10, 9);
227        assert!(range.is_empty());
228        assert_eq!(range.len(), 0);
229    }
230
231    #[test]
232    fn overflowing_file_range_is_reported() {
233        let range = FileRange::new(0, u64::MAX);
234        assert_eq!(range.checked_len(), None);
235    }
236
237    #[test]
238    fn body_plan_variants() {
239        let empty = BodyPlan::Empty;
240        assert_eq!(empty, BodyPlan::Empty);
241
242        let full = BodyPlan::FullBytes(b"hello".to_vec());
243        assert!(matches!(full, BodyPlan::FullBytes(_)));
244
245        let file_full = BodyPlan::FileFull;
246        assert_eq!(file_full, BodyPlan::FileFull);
247
248        let range = BodyPlan::FileRange {
249            start: 0,
250            end_inclusive: 4,
251        };
252        assert!(matches!(range, BodyPlan::FileRange { .. }));
253    }
254
255    #[test]
256    fn static_response_plan_status() {
257        let plan = StaticResponsePlan {
258            status: ResponseStatus::OK,
259            headers: HeaderMapPlan::new(),
260            body: BodyPlan::Empty,
261        };
262        assert_eq!(plan.status_code(), 200);
263    }
264}