1#![warn(clippy::pedantic)]
21#![forbid(unsafe_code)]
22#![deny(missing_docs)]
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Request {
27 pub method: String,
29 pub url: String,
31 pub headers: Vec<(String, String)>,
33 pub body: String,
35}
36
37#[must_use]
40pub fn parse_request(text: &str) -> Option<Request> {
41 let mut lines = text.lines().peekable();
42
43 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 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 if !url.contains("://") {
66 return None;
67 }
68
69 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 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
94pub fn send(req: &Request) -> Result<String, String> {
106 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 Ok(resp) | Err(ureq::Error::Status(_, resp)) => Ok(format_response(resp)),
128 Err(e) => Err(e.to_string()),
129 }
130}
131
132fn 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
138fn 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 let req = parse_request("GET file:///etc/passwd\n").unwrap();
214 assert!(send(&req).is_err());
215 }
216
217 use proptest::prelude::*;
220
221 proptest! {
222 #[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 #[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}