Skip to main content

wafrift_types/
request.rs

1//! HTTP method and request types — the foundation layer all wafrift crates depend on.
2//!
3//! Intentionally simple — no dependency on any HTTP library. The transport
4//! layer converts to/from `reqwest::Request` or raw bytes as needed.
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10/// HTTP method — enforced at the type level instead of a bare `String`.
11///
12/// Using an enum prevents typos like `"POSTT"` and makes exhaustive
13/// matching possible. The `Custom` variant preserves extensibility.
14///
15/// # Examples
16///
17/// Parse from a header line and route on the variant:
18///
19/// ```
20/// use wafrift_types::request::Method;
21///
22/// let m: Method = "POST".parse().unwrap();
23/// assert_eq!(m, Method::Post);
24///
25/// // Lowercase parses fine — case-insensitive.
26/// let m: Method = "delete".parse().unwrap();
27/// assert_eq!(m, Method::Delete);
28///
29/// // Anything not in the standard set becomes `Custom`.
30/// let m: Method = "PURGE".parse().unwrap();
31/// assert_eq!(m, Method::Custom("PURGE".to_string()));
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[non_exhaustive]
35pub enum Method {
36    /// HTTP GET.
37    Get,
38    /// HTTP POST.
39    Post,
40    /// HTTP PUT.
41    Put,
42    /// HTTP DELETE.
43    Delete,
44    /// HTTP PATCH.
45    Patch,
46    /// HTTP HEAD.
47    Head,
48    /// HTTP OPTIONS.
49    Options,
50    /// Non-standard or extension method.
51    Custom(String),
52}
53
54impl std::str::FromStr for Method {
55    type Err = std::convert::Infallible;
56
57    fn from_str(s: &str) -> Result<Self, Self::Err> {
58        Ok(match s.to_ascii_uppercase().as_str() {
59            "GET" => Self::Get,
60            "POST" => Self::Post,
61            "PUT" => Self::Put,
62            "DELETE" => Self::Delete,
63            "PATCH" => Self::Patch,
64            "HEAD" => Self::Head,
65            "OPTIONS" => Self::Options,
66            other => Self::Custom(other.to_string()),
67        })
68    }
69}
70
71impl Method {
72    /// Return the method as an uppercase string slice.
73    #[must_use]
74    pub fn as_str(&self) -> &str {
75        match self {
76            Self::Get => "GET",
77            Self::Post => "POST",
78            Self::Put => "PUT",
79            Self::Delete => "DELETE",
80            Self::Patch => "PATCH",
81            Self::Head => "HEAD",
82            Self::Options => "OPTIONS",
83            Self::Custom(s) => s.as_str(),
84        }
85    }
86
87    /// Check if this method typically carries a request body.
88    #[must_use]
89    pub fn has_body(&self) -> bool {
90        matches!(self, Self::Post | Self::Put | Self::Patch | Self::Custom(_))
91    }
92}
93
94impl fmt::Display for Method {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(self.as_str())
97    }
98}
99
100impl From<&str> for Method {
101    fn from(s: &str) -> Self {
102        s.parse().unwrap_or_else(|_| Method::Custom(s.to_string()))
103    }
104}
105
106impl From<String> for Method {
107    fn from(s: String) -> Self {
108        s.parse().unwrap_or(Method::Custom(s))
109    }
110}
111
112/// A request that wafrift can transform.
113///
114/// Intentionally simple — no HTTP library dependency. The transport
115/// layer converts to/from `reqwest::Request` or raw bytes as needed.
116///
117/// # Construction
118///
119/// Use the provided constructors ([`Request::get`], [`Request::post`], etc.)
120/// and builder methods ([`Request::header`], [`Request::with_body`]).
121/// Direct struct construction is prevented by `#[non_exhaustive]`.
122///
123/// # Examples
124///
125/// Build a GET request, attach a header, then a JSON POST body:
126///
127/// ```
128/// use wafrift_types::request::{Method, Request};
129///
130/// let r = Request::get("https://example.com/api")
131///     .header("Accept", "application/json");
132/// assert_eq!(r.method(), &Method::Get);
133/// assert_eq!(r.url(), "https://example.com/api");
134/// assert_eq!(r.headers().len(), 1);
135/// assert_eq!(r.get_header("Accept"), Some("application/json"));
136/// assert!(!r.has_body());
137///
138/// let r = Request::post("https://example.com/login", b"u=admin&p=admin".to_vec());
139/// assert_eq!(r.method(), &Method::Post);
140/// assert_eq!(r.body_bytes().unwrap(), b"u=admin&p=admin");
141/// ```
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[non_exhaustive]
144pub struct Request {
145    /// HTTP method.
146    pub method: Method,
147    /// Full request URL.
148    pub url: String,
149    /// Request headers as `(name, value)` pairs.
150    pub headers: Vec<(String, String)>,
151    /// Optional request body.
152    pub body: Option<Vec<u8>>,
153}
154
155impl Request {
156    /// Create a GET request.
157    pub fn get(url: impl Into<String>) -> Self {
158        Self {
159            method: Method::Get,
160            url: url.into(),
161            headers: Vec::new(),
162            body: None,
163        }
164    }
165
166    /// Create a POST request with a body.
167    pub fn post(url: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
168        Self {
169            method: Method::Post,
170            url: url.into(),
171            headers: Vec::new(),
172            body: Some(body.into()),
173        }
174    }
175
176    /// Create a PUT request with a body.
177    pub fn put(url: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
178        Self {
179            method: Method::Put,
180            url: url.into(),
181            headers: Vec::new(),
182            body: Some(body.into()),
183        }
184    }
185
186    /// Create a DELETE request.
187    pub fn delete(url: impl Into<String>) -> Self {
188        Self {
189            method: Method::Delete,
190            url: url.into(),
191            headers: Vec::new(),
192            body: None,
193        }
194    }
195
196    /// Create a request with an arbitrary method.
197    pub fn with_method(method: impl Into<Method>, url: impl Into<String>) -> Self {
198        Self {
199            method: method.into(),
200            url: url.into(),
201            headers: Vec::new(),
202            body: None,
203        }
204    }
205
206    // ── Accessors ────────────────────────────────────────────────
207
208    /// Returns a reference to the HTTP method.
209    #[must_use]
210    pub fn method(&self) -> &Method {
211        &self.method
212    }
213
214    /// Returns a mutable reference to the HTTP method.
215    pub fn method_mut(&mut self) -> &mut Method {
216        &mut self.method
217    }
218
219    /// Returns the URL as a string slice.
220    #[must_use]
221    pub fn url(&self) -> &str {
222        &self.url
223    }
224
225    /// Returns a mutable reference to the URL.
226    pub fn url_mut(&mut self) -> &mut String {
227        &mut self.url
228    }
229
230    /// Returns a slice of the header pairs.
231    #[must_use]
232    pub fn headers(&self) -> &[(String, String)] {
233        &self.headers
234    }
235
236    /// Returns a mutable reference to the headers vec.
237    pub fn headers_mut(&mut self) -> &mut Vec<(String, String)> {
238        &mut self.headers
239    }
240
241    /// Returns a reference to the body, if present.
242    #[must_use]
243    pub fn body_bytes(&self) -> Option<&[u8]> {
244        self.body.as_deref()
245    }
246
247    /// Sets the body, replacing any existing body.
248    pub fn set_body(&mut self, body: impl Into<Vec<u8>>) {
249        self.body = Some(body.into());
250    }
251
252    /// Clears the body.
253    pub fn clear_body(&mut self) {
254        self.body = None;
255    }
256
257    // ── Builder methods ──────────────────────────────────────────
258
259    /// Add a header to the request (builder pattern).
260    #[must_use]
261    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
262        self.headers.push((name.into(), value.into()));
263        self
264    }
265
266    /// Add a header to the request (mutable reference version).
267    pub fn add_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
268        self.headers.push((name.into(), value.into()));
269    }
270
271    /// Set the request body.
272    #[must_use]
273    pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
274        self.body = Some(body.into());
275        self
276    }
277
278    // ── Query methods ────────────────────────────────────────────
279
280    /// Get the value of a header by name (case-insensitive).
281    #[must_use]
282    pub fn get_header(&self, name: &str) -> Option<&str> {
283        self.headers
284            .iter()
285            .find(|(k, _)| k.eq_ignore_ascii_case(name))
286            .map(|(_, v)| v.as_str())
287    }
288
289    /// Get all values for a header name (case-insensitive).
290    #[must_use]
291    pub fn get_headers(&self, name: &str) -> Vec<&str> {
292        self.headers
293            .iter()
294            .filter(|(k, _)| k.eq_ignore_ascii_case(name))
295            .map(|(_, v)| v.as_str())
296            .collect()
297    }
298
299    /// Get the Content-Type header value.
300    #[must_use]
301    pub fn content_type(&self) -> Option<&str> {
302        self.get_header("content-type")
303    }
304
305    /// Check if this request has a body.
306    #[must_use]
307    pub fn has_body(&self) -> bool {
308        self.body.as_ref().is_some_and(|b| !b.is_empty())
309    }
310
311    /// Get the body as a UTF-8 string, if present and valid.
312    #[must_use]
313    pub fn body_str(&self) -> Option<&str> {
314        self.body.as_ref().and_then(|b| std::str::from_utf8(b).ok())
315    }
316}
317
318impl fmt::Display for Request {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        write!(f, "{} {}", self.method, self.url)?;
321        if let Some(ct) = self.content_type() {
322            write!(f, " [{ct}]")?;
323        }
324        if let Some(body) = &self.body {
325            write!(f, " ({} bytes)", body.len())?;
326        }
327        Ok(())
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn method_from_str() {
337        assert_eq!("GET".parse::<Method>().unwrap(), Method::Get);
338        assert_eq!("post".parse::<Method>().unwrap(), Method::Post);
339        assert_eq!(
340            "PURGE".parse::<Method>().unwrap(),
341            Method::Custom("PURGE".into())
342        );
343    }
344
345    #[test]
346    fn method_as_str_roundtrip() {
347        for method in &[
348            Method::Get,
349            Method::Post,
350            Method::Put,
351            Method::Delete,
352            Method::Patch,
353            Method::Head,
354            Method::Options,
355        ] {
356            assert_eq!(method.as_str().parse::<Method>().unwrap(), *method);
357        }
358    }
359
360    #[test]
361    fn method_has_body() {
362        assert!(!Method::Get.has_body());
363        assert!(Method::Post.has_body());
364        assert!(Method::Put.has_body());
365        assert!(!Method::Head.has_body());
366    }
367
368    #[test]
369    fn method_display() {
370        assert_eq!(Method::Get.to_string(), "GET");
371        assert_eq!(Method::Custom("PURGE".into()).to_string(), "PURGE");
372    }
373
374    #[test]
375    fn request_builder() {
376        let req = Request::get("https://example.com")
377            .header("X-Test", "value")
378            .header("Content-Type", "text/html");
379        assert_eq!(req.get_header("x-test"), Some("value"));
380        assert_eq!(req.content_type(), Some("text/html"));
381    }
382
383    #[test]
384    fn request_get_headers_multiple() {
385        let mut req = Request::get("https://example.com");
386        req.add_header("Cookie", "a=1");
387        req.add_header("Cookie", "b=2");
388        assert_eq!(req.get_headers("cookie").len(), 2);
389    }
390
391    #[test]
392    fn request_body_str() {
393        let req = Request::post("https://example.com", b"hello".to_vec());
394        assert_eq!(req.body_str(), Some("hello"));
395        assert!(req.has_body());
396    }
397
398    #[test]
399    fn request_display() {
400        let req = Request::post("https://example.com/api", b"data".to_vec())
401            .header("Content-Type", "application/json");
402        let display = req.to_string();
403        assert!(display.contains("POST"));
404        assert!(display.contains("example.com"));
405        assert!(display.contains("4 bytes"));
406    }
407
408    #[test]
409    fn request_equality() {
410        let a = Request::get("https://example.com");
411        let b = Request::get("https://example.com");
412        assert_eq!(a, b);
413    }
414
415    #[test]
416    fn request_with_method() {
417        let req = Request::with_method("PURGE", "https://example.com/cache");
418        assert_eq!(req.method, Method::Custom("PURGE".into()));
419    }
420
421    #[test]
422    fn request_put_and_delete() {
423        let put = Request::put("https://example.com/api", b"data".to_vec());
424        assert_eq!(put.method, Method::Put);
425        assert!(put.has_body());
426
427        let del = Request::delete("https://example.com/api/1");
428        assert_eq!(del.method, Method::Delete);
429        assert!(!del.has_body());
430    }
431
432    #[test]
433    fn request_serde_roundtrip() {
434        let req = Request::post("https://example.com", b"body".to_vec()).header("X-Test", "value");
435        let json = serde_json::to_string(&req).expect("serialize");
436        let deserialized: Request = serde_json::from_str(&json).expect("deserialize");
437        assert_eq!(req, deserialized);
438    }
439
440    #[test]
441    fn request_accessors() {
442        let req =
443            Request::post("https://example.com", b"test".to_vec()).header("Host", "example.com");
444        assert_eq!(req.url(), "https://example.com");
445        assert_eq!(*req.method(), Method::Post);
446        assert_eq!(req.headers().len(), 1);
447        assert_eq!(req.body_bytes(), Some(b"test".as_slice()));
448    }
449
450    #[test]
451    fn request_mutators() {
452        let mut req = Request::get("https://example.com");
453        req.set_body(b"new body");
454        assert!(req.has_body());
455        assert_eq!(req.body_str(), Some("new body"));
456        req.clear_body();
457        assert!(!req.has_body());
458        *req.url_mut() = "https://other.com".to_string();
459        assert_eq!(req.url(), "https://other.com");
460    }
461}