diff --git a/Cargo.lock b/Cargo.lock index 1b0c4ea..531a2e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,18 +11,64 @@ dependencies = [ "memchr", ] +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + [[package]] name = "memchr" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +[[package]] +name = "mini-internal" +version = "0.1.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fa787e06d071d09c2964b065eb7cb1b842a7af5382fc4c9142089f8383f08" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniserde" +version = "0.1.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19a2e17a11c24d44c84d7e1b61477e20cd503d024e4b0eb3e97eb9a4d24fa87" +dependencies = [ + "itoa", + "mini-internal", + "ryu", +] + [[package]] name = "once_cell" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + [[package]] name = "regex" version = "1.10.3" @@ -56,6 +102,30 @@ checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" name = "request" version = "0.1.0" dependencies = [ + "miniserde", "once_cell", "regex", ] + +[[package]] +name = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "syn" +version = "2.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" diff --git a/Cargo.toml b/Cargo.toml index f53a16e..698d7a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,3 +7,6 @@ authors = ["vodofrede"] [dependencies] once_cell = "1" regex = "1" + +[dev-dependencies] +miniserde = "0.1.38" diff --git a/src/bin/get.rs b/src/bin/get.rs index 803f4ce..2b8da50 100644 --- a/src/bin/get.rs +++ b/src/bin/get.rs @@ -1,7 +1,4 @@ -use request::*; - fn main() { - // create and send a simple request - let response = Request::get("localhost:8000").send().unwrap(); - println!("response: {:#?}", response); + let response = request::Request::get("localhost:8000").send().unwrap(); + dbg!(&response); } diff --git a/src/lib.rs b/src/lib.rs index 0cf78e0..e84e02f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,15 +9,56 @@ use once_cell::sync::Lazy; use regex::Regex; use std::{ collections::HashMap, + fmt, io::{BufRead, BufReader, Error as IoError, Write}, - net, + iter, net, }; /// An HTTP request. +/// +/// # Examples +/// +/// A simple GET request: +/// ```rust +/// use request::Request; +/// +/// // ... start a local server on port 8000 ... +/// let request = Request::get("localhost:8000"); +/// let response = request.send().unwrap(); +/// assert_eq!(response.status, 200) +/// ``` +/// +/// Adding headers: +/// ```rust +/// use request::Request; +/// +/// // ... start a local server on port 8000 ... +/// let response = Request::get("localhost:8000") +/// .header("Authorization", "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==") +/// .send() +/// .unwrap(); +/// assert_eq!(response.status, 200); +/// ``` +/// +/// A POST request with serialized JSON data. +/// ```rust +/// use request::Request; +/// +/// #[derive(miniserde::Serialize)] +/// struct Example { code: u32, message: String } +/// +/// let data = Example { code: 123, message: "hello".to_string() }; +/// let json = miniserde::json::to_string(&data); +/// let request = Request::post("example.org/api").body(&json); +/// assert_eq!( +/// format!("{request}"), +/// "POST /api HTTP/1.1\r\nHost: example.org\r\n\r\n{\"code\":123,\"message\":\"hello\"}" +/// ); +/// ``` #[derive(Debug, Clone)] pub struct Request<'a> { /// Request URL. - uri: &'a str, + url: &'a str, /// An HTTP method. GET by default. method: Method, /// Request headers. @@ -30,36 +71,80 @@ impl<'a> Request<'a> { /// Create a new request. /// /// Convenience functions are provided for each HTTP method [`Request::get`], [`Request::post`] etc. - pub fn new(uri: &'a str, method: Method) -> Self { + /// + /// # Usage + /// + /// ```rust + /// # use request::*; + /// let request = Request::new("example.org", Method::GET); + /// assert_eq!(format!("{request}"), "GET / HTTP/1.1\r\nHost: example.org\r\n\r\n"); + /// ``` + pub fn new(url: &'a str, method: Method) -> Self { Self { - uri, + url, method, headers: HashMap::new(), body: "", } } + /// Add a body to the request. + /// + /// # Usage + /// + /// ```rust + /// # use request::*; + /// let request = Request::post("example.org").body("Hello Server!"); + /// assert_eq!(format!("{request}"), "POST / HTTP/1.1\r\nHost: example.org\r\n\r\nHello Server!"); + /// ``` + pub fn body(self, body: &'a str) -> Self { + let mut request = self; + request.body = body; + request + } + + /// Add a header to the request. + /// + /// # Usage + /// + /// ```rust + /// # use request::*; + /// let request = Request::get("localhost").header("Accept", "*/*"); + /// ``` + pub fn header(self, key: &'a str, value: &'a str) -> Self { + let mut request = self; + request.headers.insert(key, value); + request + } + /// Construct a new GET request. - pub fn get(uri: &'a str) -> Self { - Request::new(uri, Method::GET) + /// + /// # Usage + /// + /// ```rust + /// # use request::*; + /// let request = Request::get("example.org"); + /// assert_eq!(format!("{request}"), "GET / HTTP/1.1\r\nHost: example.org\r\n\r\n"); + /// ``` + pub fn get(url: &'a str) -> Self { + Request::new(url, Method::GET) } /// Construct a new POST request. - pub fn post(uri: &'a str) -> Self { - Request::new(uri, Method::POST) + pub fn post(url: &'a str) -> Self { + Request::new(url, Method::POST) } /// Dispatch the request. pub fn send(&self) -> Result { - // format the message: Method Request-URI HTTP-Version CRLF headers CRLF message-body - // todo: properly format the headers - let message = format!( - "{:?} {} HTTP/1.1\r\n{:?}\r\n{}\r\n", - self.method, self.uri, self.headers, self.body - ); + // format the message + let message = format!("{self}"); + dbg!(&message); // create the stream - let mut stream = net::TcpStream::connect(self.uri)?; + // todo: resolve url with dns + let host = host(self.url).unwrap(); + let mut stream = net::TcpStream::connect(host)?; // send the message stream.write(message.as_bytes())?; @@ -68,7 +153,7 @@ impl<'a> Request<'a> { let lines = BufReader::new(stream) .lines() .map(|l| l.unwrap()) - .take_while(|l| !l.is_empty()) + .inspect(|l| println!("{l}")) .collect::>(); let received = lines.join("\n"); @@ -78,6 +163,24 @@ impl<'a> Request<'a> { Ok(response) } } +impl<'a> fmt::Display for Request<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (method, path, host, body) = ( + self.method, + path(self.url).ok_or(fmt::Error)?, + host(self.url).ok_or(fmt::Error)?, + self.body, + ); + + let headers = iter::once(format!("Host: {host}")) + .chain(self.headers.iter().map(|(k, v)| format!("{k}: {v}"))) + .collect::>() + .join("\r\n"); + + // format: Method Request-URI HTTP-Version CRLF headers CRLF CRLF message-body + write!(f, "{method:?} {path} HTTP/1.1\r\n{headers}\r\n\r\n{body}") + } +} /// HTTP methods. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -103,14 +206,14 @@ pub struct Response { pub body: Option, } impl Response { - pub fn parse(message: &str) -> Result { + fn parse(message: &str) -> Result { // construct a regex: HTTP-Version Status-Code Reason-Phrase CRLF headers CRLF message-body - static REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?PHTTP/\d\.\d) (?P\d+) (?P[a-zA-Z ]+)(?:\n(?P(?:.+\n)+))?(?:\n(?P(?:.+\n?)+))?").unwrap() + static MSG_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"(?PHTTP/\d\.\d) (?P\d+) (?P[a-zA-Z ]+)(?:\n(?P(?:.+\n)+))?(?:\n(?P[\S\s]*))?").unwrap() }); // parse the response - let Some(parts) = REGEX.captures(message) else { + let Some(parts) = MSG_REGEX.captures(message) else { Err("invalid message")? }; let version = parts["version"].to_string(); @@ -141,8 +244,21 @@ impl Response { Ok(response) } - - pub fn is_ok(&self) -> bool { - self.status == 200 - } +} + +static URI_REGEX: Lazy = Lazy::new(|| { + Regex::new("(?:(?Phttps?)://)?(?P[0-9a-zA-Z:\\.\\-]+)(?P/(?:.)*)?").unwrap() +}); +fn scheme(url: &str) -> Option<&str> { + URI_REGEX.captures(url)?.name("scheme").map(|m| m.as_str()) +} +fn host(url: &str) -> Option<&str> { + URI_REGEX.captures(url)?.name("host").map(|m| m.as_str()) +} +fn path(url: &str) -> Option<&str> { + URI_REGEX + .captures(url)? + .name("path") + .map(|m| m.as_str()) + .or(Some("/")) } diff --git a/src/tests.rs b/src/tests.rs index 056639b..965a98d 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2,35 +2,10 @@ use super::*; #[test] fn get() { - common::server(); - // create and send a simple request - let response = Request::get("localhost:8000").send().unwrap(); - println!("response: {:#?}", response); + //let response = Request::get("https://archlinux.org").send().unwrap(); // todo: dns translation + //println!("response: {:#?}", response); } #[test] fn post() {} - -mod common { - use std::{ - io::{BufRead, BufReader, Write}, - net, thread, - }; - - pub fn server() { - let listener = net::TcpListener::bind("localhost:8000").expect("port is in use."); - thread::spawn(move || { - listener - .incoming() - .filter_map(Result::ok) - .for_each(|mut t| { - let _ = BufReader::new(&mut t) - .lines() - .take_while(|l| !l.as_ref().unwrap().is_empty()) - .collect::>(); - t.write(b"HTTP/1.1 200 OK\r\n\r\n").unwrap(); - }) - }); - } -}