#![warn(clippy::pedantic)]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: String,
}
#[must_use]
pub fn parse_request(text: &str) -> Option<Request> {
let mut lines = text.lines().peekable();
let mut request_line = None;
for line in lines.by_ref() {
let t = line.trim();
if t.is_empty() || t.starts_with('#') || t.starts_with("//") {
continue;
}
request_line = Some(t);
break;
}
let request_line = request_line?;
let mut parts = request_line.split_whitespace();
let first = parts.next()?;
let (method, url) = if first.contains("://") {
("GET".to_string(), first.to_string())
} else {
(first.to_ascii_uppercase(), parts.next()?.to_string())
};
if !url.contains("://") {
return None;
}
let mut headers = Vec::new();
for line in lines.by_ref() {
let t = line.trim();
if t.is_empty() {
break;
}
if t.starts_with('#') || t.starts_with("//") {
continue;
}
if let Some((name, value)) = t.split_once(':') {
headers.push((name.trim().to_string(), value.trim().to_string()));
}
}
let body = lines.collect::<Vec<_>>().join("\n");
Some(Request {
method,
url,
headers,
body: body.trim_end().to_string(),
})
}
pub fn send(req: &Request) -> Result<String, String> {
let mut r = ureq::request(&req.method, &req.url);
for (name, value) in &req.headers {
r = r.set(name, value);
}
let result = if req.body.is_empty() {
r.call()
} else {
r.send_string(&req.body)
};
match result {
Ok(resp) | Err(ureq::Error::Status(_, resp)) => Ok(format_response(resp)),
Err(e) => Err(e.to_string()),
}
}
fn format_response(resp: ureq::Response) -> String {
use std::fmt::Write as _;
let mut out = format!(
"{} {} {}\n",
resp.http_version(),
resp.status(),
resp.status_text()
);
for name in resp.headers_names() {
if let Some(value) = resp.header(&name) {
let _ = writeln!(out, "{name}: {value}");
}
}
out.push('\n');
match resp.into_string() {
Ok(body) => out.push_str(&body),
Err(e) => {
let _ = write!(out, "<failed to read body: {e}>");
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_method_url_headers_and_body() {
let text = "POST https://e.com/x\nContent-Type: application/json\n\n{\"a\":1}\n";
let req = parse_request(text).unwrap();
assert_eq!(req.method, "POST");
assert_eq!(req.url, "https://e.com/x");
assert_eq!(
req.headers,
vec![("Content-Type".to_string(), "application/json".to_string())]
);
assert_eq!(req.body, "{\"a\":1}");
}
#[test]
fn method_defaults_to_get_and_skips_comments() {
let req = parse_request("# fetch it\nhttps://e.com/y\n").unwrap();
assert_eq!(req.method, "GET");
assert_eq!(req.url, "https://e.com/y");
assert!(req.headers.is_empty());
assert!(req.body.is_empty());
}
#[test]
fn empty_or_urlless_input_is_none() {
assert!(parse_request("").is_none());
assert!(parse_request("# only a comment\n").is_none());
assert!(parse_request("GET\n").is_none());
}
#[test]
fn parses_put_with_header_and_body() {
let req = parse_request("PUT https://e.com/z\nX-Key: 9\n\nhello").unwrap();
assert_eq!(req.method, "PUT");
assert_eq!(req.url, "https://e.com/z");
assert_eq!(req.headers, vec![("X-Key".to_string(), "9".to_string())]);
assert_eq!(req.body, "hello");
}
}