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    // Only http(s) may be requested. A `.http` buffer can be an opened file, so
107    // restricting the scheme keeps a crafted request from reaching other URL
108    // handlers (e.g. `file://`); ureq itself doesn't implement such schemes, but
109    // rejecting them explicitly is defense-in-depth and a clear error.
110    if !scheme_is_http(&req.url) {
111        return Err(format!(
112            "unsupported URL scheme (only http/https): {}",
113            req.url
114        ));
115    }
116    let mut r = ureq::request(&req.method, &req.url);
117    for (name, value) in &req.headers {
118        r = r.set(name, value);
119    }
120    let result = if req.body.is_empty() {
121        r.call()
122    } else {
123        r.send_string(&req.body)
124    };
125    match result {
126        // A success or an HTTP status error both carry a response worth showing.
127        Ok(resp) | Err(ureq::Error::Status(_, resp)) => Ok(format_response(resp)),
128        Err(e) => Err(e.to_string()),
129    }
130}
131
132/// Whether `url` begins with a permitted (`http`/`https`) scheme, case-insensitively.
133fn scheme_is_http(url: &str) -> bool {
134    let lower = url.trim_start().to_ascii_lowercase();
135    lower.starts_with("http://") || lower.starts_with("https://")
136}
137
138/// Format a `ureq` response into a readable text block (status, headers, blank
139/// line, body). Consumes `resp` to read its body.
140fn format_response(resp: ureq::Response) -> String {
141    use std::fmt::Write as _;
142    let mut out = format!(
143        "{} {} {}\n",
144        resp.http_version(),
145        resp.status(),
146        resp.status_text()
147    );
148    for name in resp.headers_names() {
149        if let Some(value) = resp.header(&name) {
150            let _ = writeln!(out, "{name}: {value}");
151        }
152    }
153    out.push('\n');
154    match resp.into_string() {
155        Ok(body) => out.push_str(&body),
156        Err(e) => {
157            let _ = write!(out, "<failed to read body: {e}>");
158        }
159    }
160    out
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn parses_method_url_headers_and_body() {
169        let text = "POST https://e.com/x\nContent-Type: application/json\n\n{\"a\":1}\n";
170        let req = parse_request(text).unwrap();
171        assert_eq!(req.method, "POST");
172        assert_eq!(req.url, "https://e.com/x");
173        assert_eq!(
174            req.headers,
175            vec![("Content-Type".to_string(), "application/json".to_string())]
176        );
177        assert_eq!(req.body, "{\"a\":1}");
178    }
179
180    #[test]
181    fn method_defaults_to_get_and_skips_comments() {
182        let req = parse_request("# fetch it\nhttps://e.com/y\n").unwrap();
183        assert_eq!(req.method, "GET");
184        assert_eq!(req.url, "https://e.com/y");
185        assert!(req.headers.is_empty());
186        assert!(req.body.is_empty());
187    }
188
189    #[test]
190    fn empty_or_urlless_input_is_none() {
191        assert!(parse_request("").is_none());
192        assert!(parse_request("# only a comment\n").is_none());
193        assert!(parse_request("GET\n").is_none());
194    }
195
196    #[test]
197    fn parses_put_with_header_and_body() {
198        let req = parse_request("PUT https://e.com/z\nX-Key: 9\n\nhello").unwrap();
199        assert_eq!(req.method, "PUT");
200        assert_eq!(req.url, "https://e.com/z");
201        assert_eq!(req.headers, vec![("X-Key".to_string(), "9".to_string())]);
202        assert_eq!(req.body, "hello");
203    }
204
205    #[test]
206    fn only_http_schemes_are_permitted() {
207        assert!(scheme_is_http("http://e.com/x"));
208        assert!(scheme_is_http("HTTPS://E.com/x"));
209        assert!(!scheme_is_http("file:///etc/passwd"));
210        assert!(!scheme_is_http("gopher://x/"));
211        assert!(!scheme_is_http("ftp://x/"));
212        // `send` refuses a non-http request without performing any I/O.
213        let req = parse_request("GET file:///etc/passwd\n").unwrap();
214        assert!(send(&req).is_err());
215    }
216
217    // ---- property-based ("fuzz") tests ------------------------------------
218
219    use proptest::prelude::*;
220
221    proptest! {
222        // Parsing an arbitrary buffer never panics; any request it yields keeps
223        // the "url has a scheme" invariant `send` relies on.
224        #[test]
225        fn parse_request_never_panics(text in ".*") {
226            if let Some(req) = parse_request(&text) {
227                prop_assert!(req.url.contains("://"), "url without scheme: {:?}", req.url);
228            }
229        }
230
231        // A parsed request whose URL is not http(s) is always refused by `send`
232        // (this runs no real I/O because the guard rejects before the request).
233        #[test]
234        fn non_http_urls_are_refused(scheme in "[a-z]{2,6}", rest in "[a-z0-9./]{1,10}") {
235            let url = format!("{scheme}://{rest}");
236            let req = Request {
237                method: "GET".into(),
238                url: url.clone(),
239                headers: vec![],
240                body: String::new(),
241            };
242            if !scheme_is_http(&url) {
243                prop_assert!(send(&req).is_err(), "non-http not refused: {url}");
244            }
245        }
246    }
247}