Skip to main content

eggserve_core/primitives/
method.rs

1//! Canonical HTTP method type.
2//!
3//! [`Method`] represents an HTTP method as a validated string, supporting
4//! both standard methods and extension methods without information loss.
5//! It is transport-independent and contains no Hyper types.
6
7use std::fmt;
8
9/// Errors from method validation.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum MethodError {
12    /// The method string is empty.
13    Empty,
14    /// The method contains invalid characters (not a valid HTTP token).
15    InvalidToken,
16}
17
18impl fmt::Display for MethodError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => write!(f, "method must not be empty"),
22            Self::InvalidToken => write!(f, "method contains invalid characters"),
23        }
24    }
25}
26
27impl std::error::Error for MethodError {}
28
29/// A validated HTTP method.
30///
31/// Standard methods (`GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `PATCH`,
32/// `OPTIONS`, `TRACE`, `CONNECT`) are recognized. Extension methods are
33/// preserved without information loss.
34///
35/// # Case sensitivity
36///
37/// HTTP methods are case-sensitive per RFC 9110 section 9.1. `Method`
38/// preserves the original casing.
39///
40/// # Validation
41///
42/// A valid method is a non-empty sequence of visible ASCII characters
43/// (code points 0x21–0x7E) excluding separators. This matches the
44/// `token` production in RFC 9110.
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub struct Method(String);
47
48impl Method {
49    /// Create a validated method from a string.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`MethodError`] if the string is empty or contains invalid
54    /// characters.
55    pub fn new(value: impl Into<String>) -> Result<Self, MethodError> {
56        let s = value.into();
57        if s.is_empty() {
58            return Err(MethodError::Empty);
59        }
60        if !is_http_token(&s) {
61            return Err(MethodError::InvalidToken);
62        }
63        Ok(Self(s))
64    }
65
66    /// Create a `Method` without validation.
67    ///
68    /// # Safety
69    ///
70    /// The caller must ensure the string is a valid HTTP token. This is
71    /// used internally for constructing standard method constants.
72    #[inline]
73    fn new_unchecked(value: &'static str) -> Self {
74        Self(value.to_string())
75    }
76
77    /// Create a `Method` for `GET`.
78    pub fn get() -> Self {
79        Self::new_unchecked("GET")
80    }
81
82    /// Create a `Method` for `HEAD`.
83    pub fn head() -> Self {
84        Self::new_unchecked("HEAD")
85    }
86
87    /// Create a `Method` for `POST`.
88    pub fn post() -> Self {
89        Self::new_unchecked("POST")
90    }
91
92    /// Create a `Method` for `PUT`.
93    pub fn put() -> Self {
94        Self::new_unchecked("PUT")
95    }
96
97    /// Create a `Method` for `DELETE`.
98    pub fn delete() -> Self {
99        Self::new_unchecked("DELETE")
100    }
101
102    /// Create a `Method` for `PATCH`.
103    pub fn patch() -> Self {
104        Self::new_unchecked("PATCH")
105    }
106
107    /// Create a `Method` for `OPTIONS`.
108    pub fn options() -> Self {
109        Self::new_unchecked("OPTIONS")
110    }
111
112    /// Create a `Method` for `TRACE`.
113    pub fn trace() -> Self {
114        Self::new_unchecked("TRACE")
115    }
116
117    /// Create a `Method` for `CONNECT`.
118    pub fn connect() -> Self {
119        Self::new_unchecked("CONNECT")
120    }
121
122    /// Returns the method as a string slice.
123    pub fn as_str(&self) -> &str {
124        &self.0
125    }
126
127    /// Returns `true` if this is `GET`.
128    pub fn is_get(&self) -> bool {
129        self.0 == "GET"
130    }
131
132    /// Returns `true` if this is `HEAD`.
133    pub fn is_head(&self) -> bool {
134        self.0 == "HEAD"
135    }
136
137    /// Returns `true` if the method is safe (does not modify resources).
138    ///
139    /// Safe methods are `GET`, `HEAD`, `OPTIONS`, and `TRACE` per RFC 9110
140    /// section 9.2.1.
141    pub fn is_safe(&self) -> bool {
142        matches!(self.0.as_str(), "GET" | "HEAD" | "OPTIONS" | "TRACE")
143    }
144
145    /// Returns `true` if the method is idempotent.
146    ///
147    /// Idempotent methods are `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS`
148    /// per RFC 9110 section 9.2.2. `TRACE` is also idempotent by definition.
149    pub fn is_idempotent(&self) -> bool {
150        matches!(
151            self.0.as_str(),
152            "GET" | "HEAD" | "PUT" | "DELETE" | "OPTIONS" | "TRACE"
153        )
154    }
155
156    /// Returns `true` if this method permits static file resolution.
157    ///
158    /// The built-in static service only supports `GET` and `HEAD`. This
159    /// method provides a policy helper without conflating method identity
160    /// with server policy.
161    pub fn permits_static_resolution(&self) -> bool {
162        self.0 == "GET" || self.0 == "HEAD"
163    }
164}
165
166impl fmt::Display for Method {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        f.write_str(&self.0)
169    }
170}
171
172impl AsRef<str> for Method {
173    fn as_ref(&self) -> &str {
174        &self.0
175    }
176}
177
178impl PartialEq<str> for Method {
179    fn eq(&self, other: &str) -> bool {
180        self.0 == other
181    }
182}
183
184impl PartialEq<&str> for Method {
185    fn eq(&self, other: &&str) -> bool {
186        self.0 == *other
187    }
188}
189
190/// Check if a string is a valid HTTP token (RFC 9110 section 5.6.2).
191fn is_http_token(s: &str) -> bool {
192    !s.is_empty()
193        && s.bytes().all(|b| matches!(b, 0x21 | 0x23..=0x27 | 0x2A | 0x2B | 0x2D..=0x2E | 0x30..=0x39 | 0x41..=0x5A | 0x5E..=0x7A | 0x7C | 0x7E))
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn standard_methods() {
202        assert_eq!(Method::get().as_str(), "GET");
203        assert_eq!(Method::head().as_str(), "HEAD");
204        assert_eq!(Method::post().as_str(), "POST");
205        assert_eq!(Method::put().as_str(), "PUT");
206        assert_eq!(Method::delete().as_str(), "DELETE");
207        assert_eq!(Method::patch().as_str(), "PATCH");
208        assert_eq!(Method::options().as_str(), "OPTIONS");
209        assert_eq!(Method::trace().as_str(), "TRACE");
210        assert_eq!(Method::connect().as_str(), "CONNECT");
211    }
212
213    #[test]
214    fn extension_method() {
215        let m = Method::new("PURGE").unwrap();
216        assert_eq!(m.as_str(), "PURGE");
217    }
218
219    #[test]
220    fn case_preserved() {
221        let m = Method::new("get").unwrap();
222        assert_eq!(m.as_str(), "get");
223        assert!(!m.is_get()); // case-sensitive
224    }
225
226    #[test]
227    fn empty_rejected() {
228        assert_eq!(Method::new("").unwrap_err(), MethodError::Empty);
229    }
230
231    #[test]
232    fn invalid_token_rejected() {
233        assert_eq!(
234            Method::new("GET POST").unwrap_err(),
235            MethodError::InvalidToken
236        );
237        assert_eq!(Method::new("GET\t").unwrap_err(), MethodError::InvalidToken);
238        assert_eq!(Method::new("").unwrap_err(), MethodError::Empty);
239    }
240
241    #[test]
242    fn valid_tokens() {
243        assert!(Method::new("X").is_ok());
244        assert!(Method::new("x-y-z").is_ok());
245        assert!(Method::new("!").is_ok());
246        assert!(Method::new("#").is_ok());
247        assert!(Method::new("0").is_ok());
248    }
249
250    #[test]
251    fn is_safe_classification() {
252        assert!(Method::get().is_safe());
253        assert!(Method::head().is_safe());
254        assert!(Method::options().is_safe());
255        assert!(Method::trace().is_safe());
256        assert!(!Method::post().is_safe());
257        assert!(!Method::put().is_safe());
258        assert!(!Method::delete().is_safe());
259        assert!(!Method::patch().is_safe());
260    }
261
262    #[test]
263    fn is_idempotent_classification() {
264        assert!(Method::get().is_idempotent());
265        assert!(Method::head().is_idempotent());
266        assert!(Method::put().is_idempotent());
267        assert!(Method::delete().is_idempotent());
268        assert!(Method::options().is_idempotent());
269        assert!(Method::trace().is_idempotent());
270        assert!(!Method::post().is_idempotent());
271        assert!(!Method::patch().is_idempotent());
272    }
273
274    #[test]
275    fn permits_static_resolution() {
276        assert!(Method::get().permits_static_resolution());
277        assert!(Method::head().permits_static_resolution());
278        assert!(!Method::post().permits_static_resolution());
279        assert!(!Method::put().permits_static_resolution());
280    }
281
282    #[test]
283    fn display() {
284        assert_eq!(format!("{}", Method::get()), "GET");
285        assert_eq!(format!("{}", Method::new("PURGE").unwrap()), "PURGE");
286    }
287
288    #[test]
289    fn eq_str() {
290        assert_eq!(Method::get(), "GET");
291        assert_eq!(Method::get(), "GET");
292        assert_ne!(Method::get(), "POST");
293    }
294
295    #[test]
296    fn method_error_display() {
297        assert!(!MethodError::Empty.to_string().is_empty());
298        assert!(!MethodError::InvalidToken.to_string().is_empty());
299    }
300
301    #[test]
302    fn method_error_is_error() {
303        let err: &dyn std::error::Error = &MethodError::Empty;
304        assert!(!err.to_string().is_empty());
305    }
306}