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