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 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 Ok(resp) | Err(ureq::Error::Status(_, resp)) => Ok(format_response(resp)),
118 Err(e) => Err(e.to_string()),
119 }
120}
121
122fn 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}