Skip to main content

api_bones/
method.rs

1//! HTTP method types.
2//!
3//! [`HttpMethod`] is a typed enum of all standard HTTP verbs, with
4//! `as_str()`, `FromStr`, `Display`, and safety/idempotency predicates.
5//!
6//! # Example
7//!
8//! ```rust
9//! use api_bones::method::HttpMethod;
10//!
11//! let m = HttpMethod::Get;
12//! assert_eq!(m.as_str(), "GET");
13//! assert!(m.is_safe());
14//! assert!(m.is_idempotent());
15//!
16//! let m2: HttpMethod = "POST".parse().unwrap();
17//! assert_eq!(m2, HttpMethod::Post);
18//! assert!(!m2.is_safe());
19//! ```
20
21use core::{fmt, str::FromStr};
22#[cfg(feature = "serde")]
23use serde::{Deserialize, Serialize};
24
25// ---------------------------------------------------------------------------
26// HttpMethod
27// ---------------------------------------------------------------------------
28
29/// Standard HTTP request methods (RFC 9110 §9).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
32#[cfg_attr(feature = "serde", serde(rename_all = "UPPERCASE"))]
33#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
34#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
35#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
36#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
37pub enum HttpMethod {
38    /// Transfer a current representation of the target resource.
39    Get,
40    /// Same as GET, but do not transfer the response body.
41    Head,
42    /// Perform resource-specific processing on the request payload.
43    Post,
44    /// Replace all current representations of the target resource.
45    Put,
46    /// Remove all current representations of the target resource.
47    Delete,
48    /// Establish a tunnel to the server identified by the target resource.
49    Connect,
50    /// Describe the communication options for the target resource.
51    Options,
52    /// Perform a message loop-back test along the path to the target resource.
53    Trace,
54    /// Apply a set of changes to the target resource.
55    Patch,
56}
57
58impl HttpMethod {
59    /// Return the uppercase string representation of this method.
60    ///
61    /// ```
62    /// use api_bones::method::HttpMethod;
63    ///
64    /// assert_eq!(HttpMethod::Delete.as_str(), "DELETE");
65    /// ```
66    #[must_use]
67    pub const fn as_str(&self) -> &'static str {
68        match self {
69            Self::Get => "GET",
70            Self::Head => "HEAD",
71            Self::Post => "POST",
72            Self::Put => "PUT",
73            Self::Delete => "DELETE",
74            Self::Connect => "CONNECT",
75            Self::Options => "OPTIONS",
76            Self::Trace => "TRACE",
77            Self::Patch => "PATCH",
78        }
79    }
80
81    /// Returns `true` for methods that are safe (read-only, no side effects).
82    ///
83    /// Safe methods per RFC 9110 §9.2.1: GET, HEAD, OPTIONS, TRACE.
84    ///
85    /// ```
86    /// use api_bones::method::HttpMethod;
87    ///
88    /// assert!(HttpMethod::Get.is_safe());
89    /// assert!(!HttpMethod::Post.is_safe());
90    /// ```
91    #[must_use]
92    pub const fn is_safe(&self) -> bool {
93        matches!(self, Self::Get | Self::Head | Self::Options | Self::Trace)
94    }
95
96    /// Returns `true` for methods that are idempotent.
97    ///
98    /// Idempotent methods per RFC 9110 §9.2.2: GET, HEAD, PUT, DELETE, OPTIONS, TRACE.
99    ///
100    /// ```
101    /// use api_bones::method::HttpMethod;
102    ///
103    /// assert!(HttpMethod::Put.is_idempotent());
104    /// assert!(!HttpMethod::Post.is_idempotent());
105    /// ```
106    #[must_use]
107    pub const fn is_idempotent(&self) -> bool {
108        matches!(
109            self,
110            Self::Get | Self::Head | Self::Put | Self::Delete | Self::Options | Self::Trace
111        )
112    }
113}
114
115// ---------------------------------------------------------------------------
116// Display / FromStr
117// ---------------------------------------------------------------------------
118
119impl fmt::Display for HttpMethod {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(self.as_str())
122    }
123}
124
125/// Error returned when parsing an [`HttpMethod`] from a string fails.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct ParseHttpMethodError;
128
129impl fmt::Display for ParseHttpMethodError {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.write_str("unknown HTTP method")
132    }
133}
134
135#[cfg(feature = "std")]
136impl std::error::Error for ParseHttpMethodError {}
137
138impl FromStr for HttpMethod {
139    type Err = ParseHttpMethodError;
140
141    fn from_str(s: &str) -> Result<Self, Self::Err> {
142        match s.trim() {
143            "GET" => Ok(Self::Get),
144            "HEAD" => Ok(Self::Head),
145            "POST" => Ok(Self::Post),
146            "PUT" => Ok(Self::Put),
147            "DELETE" => Ok(Self::Delete),
148            "CONNECT" => Ok(Self::Connect),
149            "OPTIONS" => Ok(Self::Options),
150            "TRACE" => Ok(Self::Trace),
151            "PATCH" => Ok(Self::Patch),
152            _ => Err(ParseHttpMethodError),
153        }
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Interop with `http` crate
159// ---------------------------------------------------------------------------
160
161#[cfg(feature = "http")]
162mod http_interop {
163    use super::HttpMethod;
164
165    impl From<HttpMethod> for http::Method {
166        fn from(m: HttpMethod) -> Self {
167            match m {
168                HttpMethod::Get => Self::GET,
169                HttpMethod::Head => Self::HEAD,
170                HttpMethod::Post => Self::POST,
171                HttpMethod::Put => Self::PUT,
172                HttpMethod::Delete => Self::DELETE,
173                HttpMethod::Connect => Self::CONNECT,
174                HttpMethod::Options => Self::OPTIONS,
175                HttpMethod::Trace => Self::TRACE,
176                HttpMethod::Patch => Self::PATCH,
177            }
178        }
179    }
180
181    impl TryFrom<http::Method> for HttpMethod {
182        type Error = super::ParseHttpMethodError;
183
184        fn try_from(m: http::Method) -> Result<Self, Self::Error> {
185            m.as_str().parse()
186        }
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Tests
192// ---------------------------------------------------------------------------
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn as_str_round_trips() {
200        let methods = [
201            HttpMethod::Get,
202            HttpMethod::Head,
203            HttpMethod::Post,
204            HttpMethod::Put,
205            HttpMethod::Delete,
206            HttpMethod::Connect,
207            HttpMethod::Options,
208            HttpMethod::Trace,
209            HttpMethod::Patch,
210        ];
211        for m in methods {
212            let s = m.as_str();
213            let parsed: HttpMethod = s.parse().expect("should parse");
214            assert_eq!(parsed, m, "round-trip failed for {s}");
215        }
216    }
217
218    #[test]
219    fn display_equals_as_str() {
220        assert_eq!(HttpMethod::Get.to_string(), HttpMethod::Get.as_str());
221        assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
222    }
223
224    #[test]
225    fn is_safe() {
226        assert!(HttpMethod::Get.is_safe());
227        assert!(HttpMethod::Head.is_safe());
228        assert!(HttpMethod::Options.is_safe());
229        assert!(HttpMethod::Trace.is_safe());
230        assert!(!HttpMethod::Post.is_safe());
231        assert!(!HttpMethod::Put.is_safe());
232        assert!(!HttpMethod::Delete.is_safe());
233        assert!(!HttpMethod::Connect.is_safe());
234        assert!(!HttpMethod::Patch.is_safe());
235    }
236
237    #[test]
238    fn is_idempotent() {
239        assert!(HttpMethod::Get.is_idempotent());
240        assert!(HttpMethod::Head.is_idempotent());
241        assert!(HttpMethod::Put.is_idempotent());
242        assert!(HttpMethod::Delete.is_idempotent());
243        assert!(HttpMethod::Options.is_idempotent());
244        assert!(HttpMethod::Trace.is_idempotent());
245        assert!(!HttpMethod::Post.is_idempotent());
246        assert!(!HttpMethod::Connect.is_idempotent());
247        assert!(!HttpMethod::Patch.is_idempotent());
248    }
249
250    #[test]
251    fn parse_unknown_errors() {
252        assert!("BREW".parse::<HttpMethod>().is_err());
253        assert!("get".parse::<HttpMethod>().is_err());
254    }
255
256    #[cfg(feature = "serde")]
257    #[test]
258    fn serde_round_trip() {
259        let m = HttpMethod::Patch;
260        let json = serde_json::to_string(&m).unwrap();
261        assert_eq!(json, r#""PATCH""#);
262        let back: HttpMethod = serde_json::from_str(&json).unwrap();
263        assert_eq!(back, m);
264    }
265
266    #[cfg(feature = "http")]
267    #[test]
268    fn http_crate_round_trip() {
269        let pairs = [
270            (HttpMethod::Get, http::Method::GET),
271            (HttpMethod::Head, http::Method::HEAD),
272            (HttpMethod::Post, http::Method::POST),
273            (HttpMethod::Put, http::Method::PUT),
274            (HttpMethod::Delete, http::Method::DELETE),
275            (HttpMethod::Connect, http::Method::CONNECT),
276            (HttpMethod::Options, http::Method::OPTIONS),
277            (HttpMethod::Trace, http::Method::TRACE),
278            (HttpMethod::Patch, http::Method::PATCH),
279        ];
280        for (our, theirs) in pairs {
281            let converted: http::Method = our.into();
282            assert_eq!(converted, theirs);
283            let back: HttpMethod = converted.try_into().unwrap();
284            assert_eq!(back, our);
285        }
286    }
287
288    #[test]
289    fn parse_error_display() {
290        let err = "BREW".parse::<HttpMethod>().unwrap_err();
291        assert_eq!(err.to_string(), "unknown HTTP method");
292    }
293}