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 agent: ureq::Agent = ureq::Agent::config_builder()
119 .http_status_as_error(false)
120 .build()
121 .into();
122 let mut builder = ureq::http::Request::builder()
123 .method(req.method.as_str())
124 .uri(&req.url);
125 for (name, value) in &req.headers {
126 builder = builder.header(name, value);
127 }
128 let request = builder.body(req.body.clone()).map_err(|e| e.to_string())?;
129 match agent.run(request) {
130 Ok(resp) => Ok(format_response(resp)),
131 Err(e) => Err(e.to_string()),
132 }
133}
134
135fn scheme_is_http(url: &str) -> bool {
137 let lower = url.trim_start().to_ascii_lowercase();
138 lower.starts_with("http://") || lower.starts_with("https://")
139}
140
141fn format_response(resp: ureq::http::Response<ureq::Body>) -> String {
144 use std::fmt::Write as _;
145 let status = resp.status();
146 let mut out = format!(
147 "{:?} {} {}\n",
148 resp.version(),
149 status.as_u16(),
150 status.canonical_reason().unwrap_or("")
151 );
152 for (name, value) in resp.headers() {
153 if let Ok(value) = value.to_str() {
154 let _ = writeln!(out, "{name}: {value}");
155 }
156 }
157 out.push('\n');
158 match resp.into_body().read_to_string() {
159 Ok(body) => out.push_str(&body),
160 Err(e) => {
161 let _ = write!(out, "<failed to read body: {e}>");
162 }
163 }
164 out
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn parses_method_url_headers_and_body() {
173 let text = "POST https://e.com/x\nContent-Type: application/json\n\n{\"a\":1}\n";
174 let req = parse_request(text).unwrap();
175 assert_eq!(req.method, "POST");
176 assert_eq!(req.url, "https://e.com/x");
177 assert_eq!(
178 req.headers,
179 vec![("Content-Type".to_string(), "application/json".to_string())]
180 );
181 assert_eq!(req.body, "{\"a\":1}");
182 }
183
184 #[test]
185 fn method_defaults_to_get_and_skips_comments() {
186 let req = parse_request("# fetch it\nhttps://e.com/y\n").unwrap();
187 assert_eq!(req.method, "GET");
188 assert_eq!(req.url, "https://e.com/y");
189 assert_eq!(
190 req.headers,
191 [] as [(std::string::String, std::string::String); 0]
192 );
193 assert_eq!(req.body, "");
194 }
195
196 #[test]
197 fn empty_or_urlless_input_is_none() {
198 assert!(parse_request("").is_none());
199 assert!(parse_request("# only a comment\n").is_none());
200 assert!(parse_request("GET\n").is_none());
201 }
202
203 #[test]
204 fn parses_put_with_header_and_body() {
205 let req = parse_request("PUT https://e.com/z\nX-Key: 9\n\nhello").unwrap();
206 assert_eq!(req.method, "PUT");
207 assert_eq!(req.url, "https://e.com/z");
208 assert_eq!(req.headers, vec![("X-Key".to_string(), "9".to_string())]);
209 assert_eq!(req.body, "hello");
210 }
211
212 #[test]
213 fn only_http_schemes_are_permitted() {
214 assert!(scheme_is_http("http://e.com/x"));
215 assert!(scheme_is_http("HTTPS://E.com/x"));
216 assert!(!scheme_is_http("file:///etc/passwd"));
217 assert!(!scheme_is_http("gopher://x/"));
218 assert!(!scheme_is_http("ftp://x/"));
219 let req = parse_request("GET file:///etc/passwd\n").unwrap();
221 assert!(send(&req).is_err());
222 }
223
224 use proptest::prelude::*;
227
228 proptest! {
229 #[test]
232 fn parse_request_never_panics(text in ".*") {
233 if let Some(req) = parse_request(&text) {
234 prop_assert!(req.url.contains("://"), "url without scheme: {:?}", req.url);
235 }
236 }
237
238 #[test]
241 fn non_http_urls_are_refused(scheme in "[a-z]{2,6}", rest in "[a-z0-9./]{1,10}") {
242 let url = format!("{scheme}://{rest}");
243 let req = Request {
244 method: "GET".into(),
245 url: url.clone(),
246 headers: vec![],
247 body: String::new(),
248 };
249 if !scheme_is_http(&url) {
250 prop_assert!(send(&req).is_err(), "non-http not refused: {url}");
251 }
252 }
253 }
254}