Skip to main content

eggserve_core/primitives/
request_head.rs

1//! Canonical HTTP request head.
2//!
3//! [`RequestHead`] is the transport-independent, immutable value type
4//! representing the head of an HTTP request (method, target, version,
5//! headers). It contains no Hyper types.
6
7use crate::primitives::header_block::HeaderBlock;
8use crate::primitives::method::Method;
9use crate::primitives::request_target::{RequestTarget, RequestTargetError};
10use crate::primitives::version::HttpVersion;
11
12/// Errors from converting a Hyper request to a canonical [`RequestHead`].
13#[derive(Debug)]
14pub enum RequestHeadError {
15    /// The request target could not be parsed.
16    Target(RequestTargetError),
17    /// The HTTP version is not supported.
18    Version(crate::primitives::version::HttpVersionError),
19    /// A header name is invalid.
20    HeaderName(crate::primitives::header_block::HeaderError),
21    /// A header value is invalid.
22    HeaderValue(crate::primitives::header_block::HeaderError),
23    /// The request uses an authority-form or absolute-form URI.
24    AbsoluteForm,
25}
26
27impl std::fmt::Display for RequestHeadError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Self::Target(e) => write!(f, "invalid request target: {e}"),
31            Self::Version(e) => write!(f, "unsupported HTTP version: {e}"),
32            Self::HeaderName(e) => write!(f, "invalid header name: {e}"),
33            Self::HeaderValue(e) => write!(f, "invalid header value: {e}"),
34            Self::AbsoluteForm => write!(f, "absolute-form URI not supported"),
35        }
36    }
37}
38
39impl std::error::Error for RequestHeadError {
40    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
41        match self {
42            Self::Target(e) => Some(e),
43            Self::Version(e) => Some(e),
44            Self::HeaderName(e) => Some(e),
45            Self::HeaderValue(e) => Some(e),
46            Self::AbsoluteForm => None,
47        }
48    }
49}
50
51impl From<RequestTargetError> for RequestHeadError {
52    fn from(e: RequestTargetError) -> Self {
53        Self::Target(e)
54    }
55}
56
57impl From<crate::primitives::version::HttpVersionError> for RequestHeadError {
58    fn from(e: crate::primitives::version::HttpVersionError) -> Self {
59        Self::Version(e)
60    }
61}
62
63/// A canonical, transport-independent HTTP request head.
64///
65/// Carries the method, request target, HTTP version, and headers from
66/// an HTTP request. Immutable after construction.
67///
68/// # Hyper independence
69///
70/// No Hyper type appears in this struct or its public API. Downstream
71/// code can inspect requests using only public eggserve types.
72///
73/// # Construction
74///
75/// Build through validated constructors or from the runtime adapter
76/// (Hyper → canonical conversion).
77#[derive(Debug, Clone)]
78pub struct RequestHead {
79    method: Method,
80    target: RequestTarget,
81    version: HttpVersion,
82    headers: HeaderBlock,
83}
84
85impl RequestHead {
86    /// Create a new request head.
87    ///
88    /// Prefer using [`Self::try_from_hyper`] for converting from Hyper
89    /// requests. This constructor is for direct construction in tests or
90    /// downstream code that already has validated components.
91    pub fn new(
92        method: Method,
93        target: RequestTarget,
94        version: HttpVersion,
95        headers: HeaderBlock,
96    ) -> Self {
97        Self {
98            method,
99            target,
100            version,
101            headers,
102        }
103    }
104
105    /// Returns the request method.
106    pub fn method(&self) -> &Method {
107        &self.method
108    }
109
110    /// Returns the request target.
111    pub fn target(&self) -> &RequestTarget {
112        &self.target
113    }
114
115    /// Returns the HTTP version.
116    pub fn version(&self) -> HttpVersion {
117        self.version
118    }
119
120    /// Returns the header block.
121    pub fn headers(&self) -> &HeaderBlock {
122        &self.headers
123    }
124
125    /// Returns `true` if this is a HEAD request.
126    pub fn is_head(&self) -> bool {
127        self.method.is_head()
128    }
129
130    /// Returns `true` if this is a GET request.
131    pub fn is_get(&self) -> bool {
132        self.method.is_get()
133    }
134
135    /// Returns `true` if the method permits static file resolution.
136    pub fn permits_static_resolution(&self) -> bool {
137        self.method.permits_static_resolution()
138    }
139
140    /// Convert a Hyper request into a canonical [`RequestHead`].
141    ///
142    /// Extracts method, URI, version, and headers from the Hyper request
143    /// without consuming the body. The conversion is fallible and typed:
144    /// malformed or unsupported input is rejected before handlers.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`RequestHeadError`] if the request target, version, or
149    /// headers are invalid.
150    pub fn try_from_hyper<B>(req: &hyper::Request<B>) -> Result<Self, RequestHeadError> {
151        let method =
152            Method::new(req.method().as_str()).map_err(|_| RequestHeadError::AbsoluteForm)?;
153
154        let uri = req.uri();
155        if uri.authority().is_some() {
156            return Err(RequestHeadError::AbsoluteForm);
157        }
158        let target_str = uri.to_string();
159        let target = RequestTarget::parse(target_str)?;
160
161        let version = HttpVersion::from(&req.version());
162
163        let mut headers = HeaderBlock::with_capacity(req.headers().len());
164        for (name, value) in req.headers().iter() {
165            let header_name = crate::primitives::header_block::HeaderName::new(name.as_str())
166                .map_err(RequestHeadError::HeaderName)?;
167            let value_str = value.to_str().unwrap_or("").to_string();
168            let header_value = crate::primitives::header_block::HeaderValue::new(value_str)
169                .map_err(RequestHeadError::HeaderValue)?;
170            headers.push(header_name, header_value);
171        }
172
173        Ok(Self::new(method, target, version, headers))
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::primitives::header_block::HeaderBlock;
181    use crate::primitives::method::Method;
182    use crate::primitives::request_target::RequestTarget;
183    use crate::primitives::version::HttpVersion;
184
185    fn make_head(method: &str, target: &str) -> RequestHead {
186        RequestHead::new(
187            Method::new(method).unwrap(),
188            RequestTarget::parse(target).unwrap(),
189            HttpVersion::Http11,
190            HeaderBlock::new(),
191        )
192    }
193
194    #[test]
195    fn basic_construction() {
196        let head = make_head("GET", "/");
197        assert_eq!(head.method().as_str(), "GET");
198        assert_eq!(head.target().path(), "/");
199        assert_eq!(head.version(), HttpVersion::Http11);
200        assert!(head.headers().is_empty());
201    }
202
203    #[test]
204    fn is_head() {
205        let head = make_head("HEAD", "/foo");
206        assert!(head.is_head());
207        assert!(!head.is_get());
208    }
209
210    #[test]
211    fn is_get() {
212        let head = make_head("GET", "/foo");
213        assert!(head.is_get());
214        assert!(!head.is_head());
215    }
216
217    #[test]
218    fn permits_static_resolution() {
219        let head = make_head("GET", "/");
220        assert!(head.permits_static_resolution());
221
222        let head = make_head("HEAD", "/");
223        assert!(head.permits_static_resolution());
224
225        let head = make_head("POST", "/");
226        assert!(!head.permits_static_resolution());
227    }
228
229    #[test]
230    fn with_headers() {
231        let mut headers = HeaderBlock::new();
232        headers.push_str("content-type", "text/html").unwrap();
233        let head = RequestHead::new(
234            Method::get(),
235            RequestTarget::parse("/").unwrap(),
236            HttpVersion::Http11,
237            headers,
238        );
239        assert!(head.headers().contains("content-type"));
240    }
241
242    #[test]
243    fn clone_preserves_values() {
244        let head = make_head("GET", "/foo?bar");
245        let cloned = head.clone();
246        assert_eq!(cloned.method().as_str(), "GET");
247        assert_eq!(cloned.target().path(), "/foo");
248        assert_eq!(cloned.target().query(), Some("bar"));
249    }
250}