Skip to main content

armature_h1/
method.rs

1//! Request method and protocol version.
2
3use crate::ByteStr;
4use std::fmt;
5
6/// An HTTP request method.
7///
8/// Well-known methods are unit variants, so dispatch is a discriminant
9/// comparison rather than a string comparison. Unrecognized methods carry a
10/// [`ByteStr`] slice of the read buffer.
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12pub enum Method {
13    /// `GET`
14    Get,
15    /// `HEAD`
16    Head,
17    /// `POST`
18    Post,
19    /// `PUT`
20    Put,
21    /// `DELETE`
22    Delete,
23    /// `CONNECT`
24    Connect,
25    /// `OPTIONS`
26    Options,
27    /// `TRACE`
28    Trace,
29    /// `PATCH`
30    Patch,
31    /// `QUERY` — a safe method that carries a request body.
32    Query,
33    /// Any other valid method token.
34    Other(ByteStr),
35}
36
37impl Method {
38    /// Match a method token against the well-known set.
39    ///
40    /// Returns `None` when the token is not well-known; the caller then builds
41    /// [`Method::Other`] from the read buffer. Keeping `Bytes` out of this
42    /// signature is what makes the function trivially unit-testable.
43    ///
44    /// Methods are case-sensitive (RFC 9110 section 9.1), so this compares
45    /// exactly. Dispatching on length first means most calls do one integer
46    /// comparison and one short memcmp.
47    #[inline]
48    pub fn from_bytes(token: &[u8]) -> Option<Method> {
49        match token.len() {
50            3 => match token {
51                b"GET" => Some(Method::Get),
52                b"PUT" => Some(Method::Put),
53                _ => None,
54            },
55            4 => match token {
56                b"HEAD" => Some(Method::Head),
57                b"POST" => Some(Method::Post),
58                _ => None,
59            },
60            5 => match token {
61                b"PATCH" => Some(Method::Patch),
62                b"TRACE" => Some(Method::Trace),
63                b"QUERY" => Some(Method::Query),
64                _ => None,
65            },
66            6 => match token {
67                b"DELETE" => Some(Method::Delete),
68                _ => None,
69            },
70            7 => match token {
71                b"CONNECT" => Some(Method::Connect),
72                b"OPTIONS" => Some(Method::Options),
73                _ => None,
74            },
75            _ => None,
76        }
77    }
78
79    /// The method token as a string.
80    #[inline]
81    pub fn as_str(&self) -> &str {
82        match self {
83            Method::Get => "GET",
84            Method::Head => "HEAD",
85            Method::Post => "POST",
86            Method::Put => "PUT",
87            Method::Delete => "DELETE",
88            Method::Connect => "CONNECT",
89            Method::Options => "OPTIONS",
90            Method::Trace => "TRACE",
91            Method::Patch => "PATCH",
92            Method::Query => "QUERY",
93            Method::Other(s) => s.as_str(),
94        }
95    }
96
97    /// Whether this method is safe per RFC 9110 section 9.2.1.
98    ///
99    /// Unrecognized methods are conservatively treated as unsafe.
100    #[inline]
101    pub fn is_safe(&self) -> bool {
102        matches!(
103            self,
104            Method::Get | Method::Head | Method::Options | Method::Trace | Method::Query
105        )
106    }
107
108    /// Whether a response to this method may carry a body.
109    ///
110    /// `HEAD` responses carry headers only, including the `Content-Length` the
111    /// equivalent `GET` would have produced (RFC 9112 section 6.3).
112    #[inline]
113    pub fn expects_response_body(&self) -> bool {
114        !matches!(self, Method::Head)
115    }
116}
117
118impl From<&str> for Method {
119    /// Parse a method token, falling back to [`Method::Other`].
120    ///
121    /// Infallible on purpose: this exists so `armature-core`'s constructors can
122    /// take `impl Into<Method>` and keep every existing `HttpRequest::new("GET")`
123    /// call site compiling. An invalid token is not rejected here — it is carried
124    /// as `Other` and answered by routing, which is where a 405 belongs.
125    #[inline]
126    fn from(token: &str) -> Self {
127        Method::from_bytes(token.as_bytes()).unwrap_or_else(|| Method::Other(ByteStr::from(token)))
128    }
129}
130
131impl From<String> for Method {
132    #[inline]
133    fn from(token: String) -> Self {
134        Method::from_bytes(token.as_bytes()).unwrap_or_else(|| Method::Other(ByteStr::from(token)))
135    }
136}
137
138impl PartialEq<str> for Method {
139    #[inline]
140    fn eq(&self, other: &str) -> bool {
141        self.as_str() == other
142    }
143}
144
145impl PartialEq<&str> for Method {
146    #[inline]
147    fn eq(&self, other: &&str) -> bool {
148        self.as_str() == *other
149    }
150}
151
152impl fmt::Display for Method {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        f.write_str(self.as_str())
155    }
156}
157
158/// The HTTP/1 protocol version of a message.
159#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
160pub enum Version {
161    /// `HTTP/1.0` — connections close by default.
162    Http10,
163    /// `HTTP/1.1` — connections persist by default.
164    Http11,
165}
166
167impl Version {
168    /// Map `httparse`'s minor-version byte.
169    ///
170    /// Anything other than 0 or 1 is not HTTP/1.x and must be answered with 505
171    /// rather than guessed at.
172    #[inline]
173    pub fn from_httparse(minor: u8) -> Option<Version> {
174        match minor {
175            0 => Some(Version::Http10),
176            1 => Some(Version::Http11),
177            _ => None,
178        }
179    }
180
181    /// The version token for a status line.
182    #[inline]
183    pub fn as_bytes(&self) -> &'static [u8] {
184        match self {
185            Version::Http10 => b"HTTP/1.0",
186            Version::Http11 => b"HTTP/1.1",
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn well_known_methods_parse() {
197        assert_eq!(Method::from_bytes(b"GET"), Some(Method::Get));
198        assert_eq!(Method::from_bytes(b"HEAD"), Some(Method::Head));
199        assert_eq!(Method::from_bytes(b"POST"), Some(Method::Post));
200        assert_eq!(Method::from_bytes(b"PUT"), Some(Method::Put));
201        assert_eq!(Method::from_bytes(b"DELETE"), Some(Method::Delete));
202        assert_eq!(Method::from_bytes(b"CONNECT"), Some(Method::Connect));
203        assert_eq!(Method::from_bytes(b"OPTIONS"), Some(Method::Options));
204        assert_eq!(Method::from_bytes(b"TRACE"), Some(Method::Trace));
205        assert_eq!(Method::from_bytes(b"PATCH"), Some(Method::Patch));
206        assert_eq!(Method::from_bytes(b"QUERY"), Some(Method::Query));
207    }
208
209    /// Methods are case-sensitive per RFC 9110 section 9.1.
210    #[test]
211    fn methods_are_case_sensitive() {
212        assert_eq!(Method::from_bytes(b"get"), None);
213        assert_eq!(Method::from_bytes(b"Get"), None);
214    }
215
216    #[test]
217    fn unknown_method_is_not_well_known() {
218        assert_eq!(Method::from_bytes(b"PROPFIND"), None);
219        assert_eq!(Method::from_bytes(b""), None);
220        assert_eq!(Method::from_bytes(b"GETX"), None);
221        assert_eq!(Method::from_bytes(b"GE"), None);
222    }
223
224    #[test]
225    fn as_str_round_trips() {
226        for m in [
227            Method::Get,
228            Method::Head,
229            Method::Post,
230            Method::Put,
231            Method::Delete,
232            Method::Connect,
233            Method::Options,
234            Method::Trace,
235            Method::Patch,
236            Method::Query,
237        ] {
238            assert_eq!(Method::from_bytes(m.as_str().as_bytes()), Some(m.clone()));
239        }
240        assert_eq!(
241            Method::Other(ByteStr::from_static("PROPFIND")).as_str(),
242            "PROPFIND"
243        );
244    }
245
246    #[test]
247    fn head_expects_no_response_body() {
248        assert!(!Method::Head.expects_response_body());
249        assert!(Method::Get.expects_response_body());
250    }
251
252    #[test]
253    fn safe_methods_classified() {
254        assert!(Method::Get.is_safe());
255        assert!(Method::Head.is_safe());
256        assert!(Method::Options.is_safe());
257        assert!(Method::Trace.is_safe());
258        assert!(Method::Query.is_safe());
259        assert!(!Method::Post.is_safe());
260        assert!(!Method::Delete.is_safe());
261        assert!(!Method::Other(ByteStr::from_static("PROPFIND")).is_safe());
262    }
263
264    #[test]
265    fn versions_map_from_httparse() {
266        assert_eq!(Version::from_httparse(0), Some(Version::Http10));
267        assert_eq!(Version::from_httparse(1), Some(Version::Http11));
268        assert_eq!(Version::from_httparse(2), None);
269        assert_eq!(Version::Http11.as_bytes(), b"HTTP/1.1");
270        assert_eq!(Version::Http10.as_bytes(), b"HTTP/1.0");
271    }
272
273    #[test]
274    fn from_str_maps_well_known_and_preserves_unknown_case() {
275        assert_eq!(Method::from("GET"), Method::Get);
276        assert_eq!(Method::from("QUERY"), Method::Query);
277        // Methods are case-sensitive (RFC 9110 section 9.1): a lowercase token is
278        // not GET, it is a different method token entirely.
279        assert_eq!(
280            Method::from("get"),
281            Method::Other(ByteStr::from_static("get"))
282        );
283        assert_eq!(
284            Method::from("PURGE".to_string()),
285            Method::Other(ByteStr::from_static("PURGE"))
286        );
287    }
288
289    #[test]
290    fn compares_against_str_and_displays_as_its_token() {
291        assert!(Method::Delete == "DELETE");
292        assert!(Method::Delete != "GET");
293        // Bound to a variable rather than compared inline: clippy reads
294        // `Method::from(..) == ".."` as building an owned value just to compare,
295        // which is exactly what this test is checking works.
296        let unknown = Method::from("PURGE");
297        assert!(unknown == "PURGE");
298        assert_eq!(format!("{}", Method::Patch), "PATCH");
299        assert_eq!(format!("{unknown}"), "PURGE");
300    }
301}