Skip to main content

vix_http_client/
lib.rs

1//! A minimal HTTP client driven by a `.http`-style buffer, sent with the
2//! pure-Rust `ureq` client.
3//!
4//! The buffer format (a common "REST client" shape):
5//!
6//! ```text
7//! POST https://api.example.com/things
8//! Content-Type: application/json
9//! Authorization: Bearer TOKEN
10//!
11//! {"name": "widget"}
12//! ```
13//!
14//! The first non-blank, non-comment line is `METHOD url` (the method is optional
15//! and defaults to `GET`); following lines up to a blank line are `Header: value`;
16//! everything after the blank line is the request body. Lines starting with `#`
17//! or `//` are comments. Parsing is pure and unit-tested; [`send`] performs the
18//! (blocking) request and is meant to be called from a background thread.
19
20#![warn(clippy::pedantic)]
21#![forbid(unsafe_code)]
22#![deny(missing_docs)]
23
24/// A parsed HTTP request.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Request {
27    /// HTTP method (upper-cased; defaults to `GET`).
28    pub method: String,
29    /// Request URL.
30    pub url: String,
31    /// `(name, value)` header pairs, in order.
32    pub headers: Vec<(String, String)>,
33    /// Request body (may be empty).
34    pub body: String,
35}
36
37/// Parse a `.http`-style buffer into a [`Request`]. Returns `None` if there is no
38/// request line with a URL.
39#[must_use]
40pub fn parse_request(text: &str) -> Option<Request> {
41    let mut lines = text.lines().peekable();
42
43    // First meaningful line: `[METHOD] URL`.
44    let mut request_line = None;
45    for line in lines.by_ref() {
46        let t = line.trim();
47        if t.is_empty() || t.starts_with('#') || t.starts_with("//") {
48            continue;
49        }
50        request_line = Some(t);
51        break;
52    }
53    let request_line = request_line?;
54    let mut parts = request_line.split_whitespace();
55    let first = parts.next()?;
56    // If the first token looks like a URL (has "://" or starts with "/"), the
57    // method was omitted; otherwise it's the method and the next token is the URL.
58    let (method, url) = if first.contains("://") {
59        ("GET".to_string(), first.to_string())
60    } else {
61        (first.to_ascii_uppercase(), parts.next()?.to_string())
62    };
63    // Require an absolute URL (with a scheme) so ordinary prose isn't mistaken for
64    // a request line.
65    if !url.contains("://") {
66        return None;
67    }
68
69    // Headers until a blank line.
70    let mut headers = Vec::new();
71    for line in lines.by_ref() {
72        let t = line.trim();
73        if t.is_empty() {
74            break;
75        }
76        if t.starts_with('#') || t.starts_with("//") {
77            continue;
78        }
79        if let Some((name, value)) = t.split_once(':') {
80            headers.push((name.trim().to_string(), value.trim().to_string()));
81        }
82    }
83
84    // Everything remaining is the body.
85    let body = lines.collect::<Vec<_>>().join("\n");
86    Some(Request {
87        method,
88        url,
89        headers,
90        body: body.trim_end().to_string(),
91    })
92}
93
94/// Perform `req` (blocking) and format the response as text: a status line, the
95/// response headers, a blank line, then the body. On a transport error, returns
96/// `Err` with the message; on an HTTP error status (4xx/5xx), returns the
97/// formatted error response as `Ok` (it is still a real response to show).
98///
99/// Call from a background thread — this blocks on network I/O.
100///
101/// # Errors
102/// Returns `Err` with the transport error message when the request cannot be
103/// completed (DNS, connection, TLS). An HTTP error *status* is not an error here:
104/// the formatted error response is returned as `Ok`.
105pub fn send(req: &Request) -> Result<String, String> {
106    let mut r = ureq::request(&req.method, &req.url);
107    for (name, value) in &req.headers {
108        r = r.set(name, value);
109    }
110    let result = if req.body.is_empty() {
111        r.call()
112    } else {
113        r.send_string(&req.body)
114    };
115    match result {
116        // A success or an HTTP status error both carry a response worth showing.
117        Ok(resp) | Err(ureq::Error::Status(_, resp)) => Ok(format_response(resp)),
118        Err(e) => Err(e.to_string()),
119    }
120}
121
122/// Format a `ureq` response into a readable text block (status, headers, blank
123/// line, body). Consumes `resp` to read its body.
124fn format_response(resp: ureq::Response) -> String {
125    use std::fmt::Write as _;
126    let mut out = format!(
127        "{} {} {}\n",
128        resp.http_version(),
129        resp.status(),
130        resp.status_text()
131    );
132    for name in resp.headers_names() {
133        if let Some(value) = resp.header(&name) {
134            let _ = writeln!(out, "{name}: {value}");
135        }
136    }
137    out.push('\n');
138    match resp.into_string() {
139        Ok(body) => out.push_str(&body),
140        Err(e) => {
141            let _ = write!(out, "<failed to read body: {e}>");
142        }
143    }
144    out
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn parses_method_url_headers_and_body() {
153        let text = "POST https://e.com/x\nContent-Type: application/json\n\n{\"a\":1}\n";
154        let req = parse_request(text).unwrap();
155        assert_eq!(req.method, "POST");
156        assert_eq!(req.url, "https://e.com/x");
157        assert_eq!(
158            req.headers,
159            vec![("Content-Type".to_string(), "application/json".to_string())]
160        );
161        assert_eq!(req.body, "{\"a\":1}");
162    }
163
164    #[test]
165    fn method_defaults_to_get_and_skips_comments() {
166        let req = parse_request("# fetch it\nhttps://e.com/y\n").unwrap();
167        assert_eq!(req.method, "GET");
168        assert_eq!(req.url, "https://e.com/y");
169        assert!(req.headers.is_empty());
170        assert!(req.body.is_empty());
171    }
172
173    #[test]
174    fn empty_or_urlless_input_is_none() {
175        assert!(parse_request("").is_none());
176        assert!(parse_request("# only a comment\n").is_none());
177        assert!(parse_request("GET\n").is_none());
178    }
179
180    #[test]
181    fn parses_put_with_header_and_body() {
182        let req = parse_request("PUT https://e.com/z\nX-Key: 9\n\nhello").unwrap();
183        assert_eq!(req.method, "PUT");
184        assert_eq!(req.url, "https://e.com/z");
185        assert_eq!(req.headers, vec![("X-Key".to_string(), "9".to_string())]);
186        assert_eq!(req.body, "hello");
187    }
188}