diff --git a/Cargo.toml b/Cargo.toml index 495bbc9..9c789b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,17 @@ [package] -edition = "2024" name = "hteapot" version = "0.5.0" -exclude = ["config.toml", "demo/", "readme.md"] -license = "MIT" -keywords = ["HTTP", "HTTP-SERVER"] +edition = "2024" +authors = ["Alb Ruiz G. "] description = "HTeaPot is a lightweight HTTP server library designed to be easy to use and extend." +license = "MIT" +readme = "README.md" +documentation = "https://docs.rs/hteapot/" homepage = "https://github.com/az107/HTeaPot" repository = "https://github.com/az107/HTeaPot" -readme = "readme.md" -categories = ["network-programming", "web-programming"] -authors = ["Alb Ruiz G. "] +keywords = ["http", "server", "web", "lightweight", "rust"] +categories = ["network-programming", "web-programming", "command-line-utilities"] +exclude = ["config.toml", "demo/", "README.md"] [lib] name = "hteapot" @@ -18,3 +19,6 @@ path = "src/hteapot/mod.rs" [[bin]] name = "hteapot" + +[package.metadata.docs.rs] +no-readme = true \ No newline at end of file diff --git a/README.md b/README.md index 3137911..d1bcc20 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,19 @@ A high-performance, lightweight HTTP server and library built in Rust. HTeaPot is designed to deliver exceptional performance for modern web applications while maintaining a simple and intuitive API. -## Features +## ๐Ÿ“š Table of Contents + +- [Features](#--features) +- [Getting Started](#-getting-started) + - [Standalone Server](#standalone-server) + - [As a Library](#as-a-library) +- [Performance](#-performance) +- [Roadmap](#-roadmap) +- [Contributing](#-contributing) +- [License](#-license) +- [Acknowledgments](#-acknowledgments) + +## โœจ Features ### Exceptional Performance - **Threaded Architecture**: Powered by a custom-designed thread system that handles **70,000+ requests per second** @@ -34,9 +46,9 @@ A high-performance, lightweight HTTP server and library built in Rust. HTeaPot i - **Extensible Design**: Easily customize behavior for specific use cases - **Lightweight Footprint**: Zero dependencies and efficient resource usage -## Getting Started +## ๐Ÿš€ Getting Started -### Installation +### ๐Ÿ”ง Installation ```bash # Install from crates.io @@ -48,11 +60,11 @@ cd hteapot cargo build --release ``` -### Standalone Server +### ๐Ÿ–ฅ๏ธ Running the Server -#### Using a configuration file: +#### Option 1: With Config -Create a `config.toml` file: +1. Create a `config.toml` file: ```toml [HTEAPOT] @@ -61,27 +73,27 @@ host = "localhost" # The host address to bind to root = "public" # The root directory to serve files from ``` -Run the server: +2. Run the server: ```bash hteapot ./config.toml ``` -#### Quick serve a directory: +#### Option 2: Quick Serve ```bash hteapot -s ./public/ ``` -### As a Library +### ๐Ÿฆ€ Using as a Library -1. Add HTeaPot to your project: +1. Add HTeaPot to your ```Cargo.toml``` project: ```bash cargo add hteapot ``` -2. Implement in your code: +2. Implement in your code: ```example``` ```rust use hteapot::{HttpStatus, HttpResponse, Hteapot, HttpRequest}; @@ -97,16 +109,16 @@ fn main() { } ``` -## Performance +## ๐Ÿ“Š Performance HTeaPot has been benchmarked against other popular HTTP servers, consistently demonstrating excellent metrics: -| Metric | HTeaPot | Industry Average | -|---------------|---------|------------------| -| Requests/sec | 70,000+ | 30,000-50,000 | -| Error rate | <0.1% | 0.5-2% | -| Latency (p99) | 5ms | 15-30ms | -| Memory usage | Low | Moderate | +| Metric | HTeaPot | Industry Average | +|---------------|---------------|------------------------| +| Requests/sec | 70,000+ req/s | 30,000 - 50,000 req/s | +| Error rate | < 0.1% | 0.5% - 2% | +| Latency (p99) | 5ms | 15ms - 30ms | +| Memory usage | Low | Moderate | ## Roadmap @@ -122,7 +134,19 @@ HTeaPot has been benchmarked against other popular HTTP servers, consistently de ## Contributing -We welcome contributions from the community! See our [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to get involved. +We welcome contributions from the community! To get started: + +```sh +# Format the code +cargo fmt + +# Lint for warnings +cargo clippy --all-targets --all-features + +# Run tests +cargo test +``` +See [CONTRIBUTING.md](https://github.com/Az107/HTeaPot/wiki/Contributing) for more details. ## License diff --git a/src/cache.rs b/src/cache.rs index 53107bc..16f1c02 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,18 +1,38 @@ // Written by Alberto Ruiz, 2024-11-05 +// // Config module: handles application configuration setup and parsing. // This module defines structs and functions to load and validate // configuration settings from files, environment variables, or other sources. + use std::collections::HashMap; use std::time; use std::time::SystemTime; +/// A simple in-memory cache with TTL (time-to-live) support. +/// +/// This cache stores byte arrays (`Vec`) along with an expiration timestamp. +/// When a cached entry is fetched, the TTL is validated. If it's expired, the +/// item is removed and `None` is returned. +/// +/// Note: Currently not generic, but could be extended in the future to support +/// other data types. +/// +/// # Example +/// ``` +/// let mut cache = Cache::new(60); // 60 seconds TTL +/// cache.set("hello".into(), vec![1, 2, 3]); +/// let data = cache.get("hello".into()); +/// assert!(data.is_some()); +/// ``` pub struct Cache { - //TODO: consider make it generic + // TODO: consider make it generic + // The internal store: (data, expiration timestamp) data: HashMap, u64)>, max_ttl: u64, } impl Cache { + /// Creates a new `Cache` with the specified TTL in seconds. pub fn new(max_ttl: u64) -> Self { Cache { data: HashMap::new(), @@ -20,6 +40,7 @@ impl Cache { } } + /// Creates a new `Cache` with the specified TTL in seconds. fn validate_ttl(&self, ttl: u64) -> bool { let now = SystemTime::now(); let since_epoch = now @@ -29,6 +50,7 @@ impl Cache { secs < ttl } + /// Computes the expiration timestamp for a new cache entry. fn get_ttl(&self) -> u64 { let now = SystemTime::now(); let since_epoch = now @@ -38,10 +60,14 @@ impl Cache { secs + self.max_ttl } + /// Stores data in the cache with the given key and a TTL. pub fn set(&mut self, key: String, data: Vec) { self.data.insert(key, (data, self.get_ttl())); } + /// Retrieves data from the cache if it exists and hasn't expired. + /// + /// Removes and returns `None` if the TTL has expired. pub fn get(&mut self, key: String) -> Option> { let r = self.data.get(&key); if r.is_some() { @@ -55,5 +81,16 @@ impl Cache { } else { None } + + // Alternative implementation using pattern matching + // This is a more idiomatic way to handle the Option type in Rust. + // match self.data.get(&key) { + // Some((data, ttl)) if self.validate_ttl(*ttl) => Some(data.clone()), + // Some(_) => { + // self.data.remove(&key); + // None + // } + // None => None, + // } } } diff --git a/src/config.rs b/src/config.rs index fb689a5..42e68c1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,15 @@ // Written by Alberto Ruiz 2024-04-07 (Happy 3th monthsary) -// This is the config module, it will load the configuration -// file and provide the settings +// +// This is the config module: responsible for loading application configuration +// from a file and providing structured access to settings. use std::{any::Any, collections::HashMap, fs}; +/// Dynamic TOML value representation. +/// +/// Each parsed TOML key is stored as a `TOMLtype`, which can be a string, +/// number, float, or boolean. This allows the custom parser to support basic +/// TOML configuration without external dependencies. #[derive(Clone, Debug)] pub enum TOMLtype { Text(String), @@ -12,8 +18,12 @@ pub enum TOMLtype { Boolean(bool), } +/// A section of the parsed TOML file, keyed by strings and holding `TOMLtype` values. type TOMLSchema = HashMap; + +/// Trait for safely extracting typed values from a `TOMLSchema`. trait Schema { + /// Attempts to retrieve a value of type `T` from the schema by key. fn get2(&self, key: &str) -> Option; } @@ -21,12 +31,16 @@ impl Schema for TOMLSchema { fn get2(&self, key: &str) -> Option { let value = self.get(key)?; let value = value.clone(); + + // Convert the TOMLtype to a dynamically typed value let any_value: Box = match value { TOMLtype::Text(d) => Box::new(d), TOMLtype::Number(d) => Box::new(d), TOMLtype::Float(d) => Box::new(d), TOMLtype::Boolean(d) => Box::new(d), }; + + // Try to downcast to the requested type let r = any_value.downcast_ref::().cloned(); if r.is_none() { println!("{} is none", key); @@ -35,22 +49,37 @@ impl Schema for TOMLSchema { } } +/// Parses a TOML-like string into a nested `HashMap` structure. +/// +/// This is a minimal, custom TOML parser that supports: +/// - Sections (e.g., `[HTEAPOT]`) +/// - Key-value pairs with types: string, bool, u16, f64 +/// - Ignores comments and blank lines +/// +/// # Panics +/// Panics if a numeric or float value fails to parse. pub fn toml_parser(content: &str) -> HashMap { let mut map = HashMap::new(); let mut submap = HashMap::new(); let mut title = "".to_string(); + let lines = content.split("\n"); for line in lines { if line.starts_with("#") || line.is_empty() { continue; } + + // Remove trailing inline comments let line = if line.contains('#') { let parts = line.split("#").collect::>(); parts[0].trim() } else { line.trim() }; + + // Skip empty lines if line.starts_with("[") && line.ends_with("]") { + // New section starts let key = line.trim_matches('[').trim_matches(']').trim(); if submap.len() != 0 && title.len() != 0 { map.insert(title.clone(), submap.clone()); @@ -59,10 +88,14 @@ pub fn toml_parser(content: &str) -> HashMap { submap = HashMap::new(); continue; } + + // Split key and value let parts = line.split("=").collect::>(); if parts.len() != 2 { continue; } + + // Remove leading and trailing whitespace let key = parts[0] .trim() .trim_end_matches('"') @@ -70,6 +103,8 @@ pub fn toml_parser(content: &str) -> HashMap { if key.is_empty() { continue; } + + // Remove leading and trailing whitespace let value = parts[1].trim(); let value = if value.contains('\'') || value.contains('"') { let value = value.trim_matches('"').trim(); @@ -90,23 +125,42 @@ pub fn toml_parser(content: &str) -> HashMap { } TOMLtype::Number(value.unwrap()) }; + + // Suggested alternative parsing logic + // let value = if value.contains('\'') || value.contains('"') { + // TOMLtype::Text(value.trim_matches('"').to_string()) + // } else if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") { + // TOMLtype::Boolean(value.eq_ignore_ascii_case("true")) + // } else if value.contains('.') { + // TOMLtype::Float(value.parse().expect("Error parsing float")) + // } else { + // TOMLtype::Number(value.parse().expect("Error parsing number")) + // }; + + // Insert the key-value pair into the submap submap.insert(key.to_string(), value); } + + // Insert the last section if it exists map.insert(title, submap.clone()); map } +/// Configuration for the HTeaPot server. +/// +/// This struct holds the runtime settings for the server, +/// such as host, port, caching behavior, and proxy rules. #[derive(Debug)] pub struct Config { - pub port: u16, // Port number to listen - pub host: String, // Host name or IP - pub root: String, // Root directory to serve files + pub port: u16, // Port number to listen + pub host: String, // Host name or IP + pub root: String, // Root directory to serve files pub cache: bool, pub cache_ttl: u16, pub threads: u16, pub log_file: Option, - pub index: String, // Index file to serve by default - //pub error: String, // Error file to serve when a file is not found + pub index: String, // Index file to serve by default + // pub error: String, // Error file to serve when a file is not found pub proxy_rules: HashMap, } @@ -122,6 +176,7 @@ impl Config { // } // } + /// Returns a default configuration with sensible values. pub fn new_default() -> Config { Config { port: 8080, @@ -137,13 +192,21 @@ impl Config { } } + /// Loads configuration from a TOML file, returning defaults on failure. + /// + /// Expects the file to contain `[HTEAPOT]` and optionally `[proxy]` sections. + /// Supports type-safe extraction for each expected key. pub fn load_config(path: &str) -> Config { let content = fs::read_to_string(path); if content.is_err() { return Config::new_default(); } + + // Read the file content let content = content.unwrap(); let map = toml_parser(&content); + + // Extract proxy rules let mut proxy_rules: HashMap = HashMap::new(); let proxy_map = map.get("proxy"); if proxy_map.is_some() { @@ -151,7 +214,7 @@ impl Config { for k in proxy_map.keys() { let url = proxy_map.get2(k); if url.is_none() { - println!(); + println!("Missing or invalid proxy URL for key: {}", k); continue; } let url = url.unwrap(); @@ -159,7 +222,24 @@ impl Config { } } + // Suggested alternative parsing logic + // if let Some(proxy_map) = map.get("proxy") { + // for k in proxy_map.keys() { + // if let Some(url) = proxy_map.get2(k) { + // proxy_rules.insert(k.clone(), url); + // } else { + // println!("Missing or invalid proxy URL for key: {}", k); + // } + // } + // } + + // Extract main configuration let map = map.get("HTEAPOT").unwrap(); + + // Suggested alternative parsing logic (Not working) + // let map = map.get("HTEAPOT").unwrap_or(&TOMLSchema::new()); + + Config { port: map.get2("port").unwrap_or(8080), host: map.get2("host").unwrap_or("".to_string()), diff --git a/src/hteapot/brew.rs b/src/hteapot/brew.rs index 59b141b..a7a49ce 100644 --- a/src/hteapot/brew.rs +++ b/src/hteapot/brew.rs @@ -1,5 +1,8 @@ // Written by Alberto Ruiz 2024-04-08 -// This is the HTTP client module, it will handle the requests and responses +// +// This module provides basic HTTP client functionality. It defines +// methods to compose and send HTTP requests and parse the resulting +// responses using a `TcpStream`. use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; @@ -11,71 +14,85 @@ use super::response::HttpResponse; // use std::net::{IpAddr, Ipv4Addr, SocketAddr}; impl HttpRequest { + /// Adds a query argument to the HTTP request. pub fn arg(&mut self, key: &str, value: &str) -> &mut HttpRequest { self.args.insert(key.to_string(), value.to_string()); - return self; + self } + /// Adds a header to the HTTP request. pub fn header(&mut self, key: &str, value: &str) -> &mut HttpRequest { self.headers.insert(key.to_string(), value.to_string()); - return self; + self } + /// Converts the request into a raw HTTP/1.1-compliant string. + /// + /// This includes method, path with optional query args, headers, and optional body. pub fn to_string(&self) -> String { + // Add query parameters to the path if needed let path = if self.args.is_empty() { self.path.clone() } else { - let mut path = self.path.clone(); - path.push('?'); - for (k, v) in self.args.iter() { - path.push_str(format!("{}={}&", k, v).as_str()); + let mut path = format!("{}?", self.path); + for (k, v) in &self.args { + path.push_str(&format!("{}={}&", k, v)); } if path.ends_with('&') { path.pop(); } path }; - let path = if path.is_empty() { - "/".to_string() - } else { - path - }; - let mut result: String = format!("{} {} HTTP/1.1\r\n", self.method.to_str(), path); - for (k, v) in self.headers.iter() { - result.push_str(format!("{}: {}\r\n", k, v).as_str()); - } - result.push_str(self.text().unwrap_or(String::new()).as_str()); + let path = if path.is_empty() { "/".to_string() } else { path }; + let mut result = format!("{} {} HTTP/1.1\r\n", self.method.to_str(), path); + for (k, v) in &self.headers { + result.push_str(&format!("{}: {}\r\n", k, v)); + } + + result.push_str(&self.text().unwrap_or_default()); result.push_str("\r\n\r\n"); + result } + /// Sends the request to a remote server and returns a parsed response. + /// + /// Supports only `http://` (not `https://`). Attempts to resolve the domain + /// and open a TCP connection. Times out after 5 seconds. pub fn brew(&self, addr: &str) -> Result, &'static str> { let mut addr = addr.to_string(); - if addr.starts_with("http://") { - addr = addr.strip_prefix("http://").unwrap().to_string(); + + // Strip protocol prefix + if let Some(stripped) = addr.strip_prefix("http://") { + addr = stripped.to_string(); } else if addr.starts_with("https://") { - return Err("Not implemented yet"); + return Err("HTTPS not implemented yet"); } + + // Add port if missing if !addr.contains(':') { - let _addr = format!("{}:80", addr.clone()); - addr = _addr + addr.push_str(":80"); } - let addr: Vec<_> = addr + + // Resolve address + let resolved_addrs: Vec<_> = addr .to_socket_addrs() - .expect("Unable to resolve domain") + .map_err(|_| "Unable to resolve domain")? .collect(); - let addr = addr.first().expect("Error parsing address"); - let stream = TcpStream::connect_timeout(addr, Duration::from_secs(5)); - if stream.is_err() { - return Err("Error connecting to server"); - } - let mut stream = stream.unwrap(); - let _ = stream.write(self.to_string().as_bytes()); + let socket_addr = resolved_addrs.first().ok_or("No address found")?; + + // Connect to server + let stream = TcpStream::connect_timeout(socket_addr, Duration::from_secs(5)) + .map_err(|_| "Error connecting to server")?; + + let mut stream = stream; + let _ = stream.write_all(self.to_string().as_bytes()); let _ = stream.flush(); let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let mut raw: Vec = Vec::new(); let _ = stream.read_to_end(&mut raw); @@ -83,8 +100,11 @@ impl HttpRequest { } } +/// Alias to send a request via `request.brew()`. +/// +/// Useful for calling as a standalone function. pub fn brew(direction: &str, request: &mut HttpRequest) -> Result, &'static str> { - return request.brew(direction); + request.brew(direction) } // pub fn brew_url(url: &str) -> Result { diff --git a/src/hteapot/methods.rs b/src/hteapot/methods.rs index 6f43a4e..78592f6 100644 --- a/src/hteapot/methods.rs +++ b/src/hteapot/methods.rs @@ -1,3 +1,7 @@ +/// Represents an HTTP method (verb). +/// +/// Includes standard HTTP/1.1 methods such as `GET`, `POST`, `PUT`, etc., +/// and a catch-all variant `Other(String)` for unknown or non-standard methods. #[derive(Debug, PartialEq, Eq, Hash, Clone)] pub enum HttpMethod { GET, @@ -12,6 +16,21 @@ pub enum HttpMethod { Other(String), } impl HttpMethod { + /// Creates an `HttpMethod` from a raw string (case-sensitive). + /// + /// If the method is not one of the standard HTTP methods, + /// it will be returned as `HttpMethod::Other(method.to_string())`. + /// + /// # Examples + /// ``` + /// use hteapot::HttpMethod; + /// + /// let m = HttpMethod::from_str("GET"); + /// assert_eq!(m, HttpMethod::GET); + /// + /// let custom = HttpMethod::from_str("CUSTOM"); + /// assert_eq!(custom, HttpMethod::Other("CUSTOM".into())); + /// ``` pub fn from_str(method: &str) -> HttpMethod { match method { "GET" => HttpMethod::GET, @@ -26,6 +45,18 @@ impl HttpMethod { _ => Self::Other(method.to_string()), } } + + /// Returns the string representation of the HTTP method. + /// + /// If the method is non-standard (`Other`), it returns the inner string as-is. + /// + /// # Examples + /// ``` + /// use hteapot::HttpMethod; + /// + /// let method = HttpMethod::GET; + /// assert_eq!(method.to_str(), "GET"); + /// ``` pub fn to_str(&self) -> &str { match self { HttpMethod::GET => "GET", diff --git a/src/hteapot/mod.rs b/src/hteapot/mod.rs index 87ef9c2..c03acd3 100644 --- a/src/hteapot/mod.rs +++ b/src/hteapot/mod.rs @@ -1,17 +1,33 @@ // Written by Alberto Ruiz 2024-03-08 +// // This is the HTTP server module, it will handle the requests and responses -// Also provide utilities to parse the requests and build the responses - -pub mod brew; -mod methods; -mod request; -mod response; -mod status; - -use self::response::EmptyHttpResponse; -use self::response::HttpResponseCommon; -use self::response::IterError; - +// Also provides utilities to parse the requests and build the response + +//! HTeaPot HTTP server core. +//! +//! This module provides a multithreaded HTTP/1.1 server built for performance and ease of use. +//! It handles request parsing, response building, connection lifecycle (keep-alive) +//! and hooks. +//! +//! Core types: +//! - [`Hteapot`] โ€” the main server entry point +//! - [`HttpRequest`] and [`HttpResponse`] โ€” re-exported from submodules +//! +//! Use [`Hteapot::listen`] to start a server with a request handler closure. +//! ``` + +/// Submodules for HTTP functionality. +pub mod brew; // HTTP client implementation +mod methods; // HTTP method and status enums +mod request; // Request parsing and builder +mod response; // Response types and streaming +mod status; // Status code mapping + +// Internal types used for connection management +use self::response::{EmptyHttpResponse, HttpResponseCommon, IterError}; +// use std::sync::atomic::{AtomicBool, Ordering}; + +// Public API exposed by this module pub use self::methods::HttpMethod; pub use self::request::HttpRequest; use self::request::HttpRequestBuilder; @@ -25,10 +41,25 @@ use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant}; +/// Crate version as set by `Cargo.toml`. const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Size of the buffer used for reading from the TCP stream. const BUFFER_SIZE: usize = 1024 * 2; + +/// Time-to-live for keep-alive connections. const KEEP_ALIVE_TTL: Duration = Duration::from_secs(10); +/// Helper macro to construct header maps. +/// +/// # Example +/// ```rust +/// use hteapot::headers; +/// let headers = headers! { +/// "Content-Type" => "text/html", +/// "X-Custom" => "value" +/// }; +/// ``` #[macro_export] macro_rules! headers { ( $($k:expr => $v:expr),*) => { @@ -47,6 +78,7 @@ pub struct Hteapot { threads: u16, } +/// Represents the state of a connection's lifecycle. struct SocketStatus { ttl: Instant, reading: bool, @@ -56,6 +88,7 @@ struct SocketStatus { index_writed: usize, } +/// Wraps a TCP stream and its associated state. struct SocketData { stream: TcpStream, status: Option, @@ -85,11 +118,10 @@ impl Hteapot { action: impl Fn(HttpRequest) -> Box + Send + Sync + 'static, ) { let addr = format!("{}:{}", self.address, self.port); - let listener = TcpListener::bind(addr); - let listener = match listener { + let listener = match TcpListener::bind(addr) { Ok(listener) => listener, Err(e) => { - eprintln!("Error L: {}", e); + eprintln!("Error binding to address: {}", e); return; } }; @@ -113,8 +145,8 @@ impl Hteapot { let mut pool = lock.lock().expect("Error locking pool"); if streams_to_handle.is_empty() { - pool = cvar - .wait_while(pool, |pool| pool.is_empty()) + // Store the returned guard back into pool + pool = cvar.wait_while(pool, |pool| pool.is_empty()) .expect("Error waiting on cvar"); } @@ -153,15 +185,19 @@ impl Hteapot { } loop { - let stream = listener.accept(); - if stream.is_err() { + let stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(_) => continue, + }; + + if stream.set_nonblocking(true).is_err() { + eprintln!("Error setting non-blocking mode on stream"); + continue; + } + if stream.set_nodelay(true).is_err() { + eprintln!("Error setting no delay on stream"); continue; } - let (stream, _) = stream.unwrap(); - stream - .set_nonblocking(true) - .expect("Error setting non-blocking"); - stream.set_nodelay(true).expect("Error setting no delay"); { let (lock, cvar) = &*pool; @@ -183,23 +219,20 @@ impl Hteapot { // Fix by miky-rola 2025-04-08 // Check if the TTL (time-to-live) for the connection has expired. // If the connection is idle for longer than `KEEP_ALIVE_TTL` and no data is being written, - // the connection is gracefully shut down to free resources. + // the connection is gracefully shut down to free resources. if Instant::now().duration_since(status.ttl) > KEEP_ALIVE_TTL && !status.write { let _ = socket_data.stream.shutdown(Shutdown::Both); return None; } // If the request is not yet complete, read data from the stream into a buffer. // This ensures that the server can handle partial or chunked requests. + if !status.request.done { let mut buffer = [0; BUFFER_SIZE]; match socket_data.stream.read(&mut buffer) { Err(e) => match e.kind() { - io::ErrorKind::WouldBlock => { - return Some(()); - } - io::ErrorKind::ConnectionReset => { - return None; - } + io::ErrorKind::WouldBlock => return Some(()), + io::ErrorKind::ConnectionReset => return None, _ => { eprintln!("Read error: {:?}", e); return None; @@ -225,12 +258,7 @@ impl Hteapot { } } - let request = status.request.get(); - if request.is_none() { - return Some(()); - } - let request = request.unwrap(); - + let request = status.request.get()?; let keep_alive = request .headers .get("connection") //all headers are turn lowercase in the builder @@ -267,9 +295,7 @@ impl Hteapot { status.ttl = Instant::now(); let _ = status.response.next(); } - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { - return Some(()); - } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => return Some(()), Err(e) => { eprintln!("Write error: {:?}", e); return None; @@ -297,68 +323,71 @@ impl Hteapot { } #[cfg(test)] -#[test] -fn test_http_response_maker() { - let mut response = HttpResponse::new(HttpStatus::IAmATeapot, "Hello, World!", None); - let response = String::from_utf8(response.to_bytes()).unwrap(); - let expected_response = format!( - "HTTP/1.1 418 I'm a teapot\r\nContent-Length: 13\r\nServer: HTeaPot/{}\r\n\r\nHello, World!\r\n", - VERSION - ); - let expected_response_list = expected_response.split("\r\n"); - for item in expected_response_list.into_iter() { - assert!(response.contains(item)); +mod tests { + use super::*; + + #[test] + fn test_http_response_maker() { + let mut response = HttpResponse::new(HttpStatus::IAmATeapot, "Hello, World!", None); + let response = String::from_utf8(response.to_bytes()).unwrap(); + let expected_response = format!( + "HTTP/1.1 418 I'm a teapot\r\nContent-Length: 13\r\nServer: HTeaPot/{}\r\n\r\nHello, World!\r\n", + VERSION + ); + let expected_response_list = expected_response.split("\r\n"); + for item in expected_response_list { + assert!(response.contains(item)); + } } -} -#[cfg(test)] -#[test] -fn test_keep_alive_connection() { - let mut response = HttpResponse::new( - HttpStatus::OK, - "Keep-Alive Test", - headers! { - "Connection" => "keep-alive", - "Content-Length" => "15" - }, - ); - - response.base().headers.insert( - "Keep-Alive".to_string(), - format!("timeout={}", KEEP_ALIVE_TTL.as_secs()), - ); - - let response_bytes = response.to_bytes(); - let response_str = String::from_utf8(response_bytes.clone()).unwrap(); - - assert!(response_str.contains("HTTP/1.1 200 OK")); - assert!(response_str.contains("Content-Length: 15")); - assert!(response_str.contains("Connection: keep-alive")); - assert!(response_str.contains("Keep-Alive: timeout=10")); - assert!(response_str.contains("Server: HTeaPot/")); - assert!(response_str.contains("Keep-Alive Test")); - - let mut second_response = HttpResponse::new( - HttpStatus::OK, - "Second Request", - headers! { - "Connection" => "keep-alive", - "Content-Length" => "14" // Length for "Second Request" - }, - ); - - second_response.base().headers.insert( - "Keep-Alive".to_string(), - format!("timeout={}", KEEP_ALIVE_TTL.as_secs()), - ); - - let second_response_bytes = second_response.to_bytes(); - let second_response_str = String::from_utf8(second_response_bytes.clone()).unwrap(); - - assert!(second_response_str.contains("HTTP/1.1 200 OK")); - assert!(second_response_str.contains("Content-Length: 14")); - assert!(second_response_str.contains("Connection: keep-alive")); - assert!(second_response_str.contains("Keep-Alive: timeout=10")); - assert!(second_response_str.contains("Server: HTeaPot/")); - assert!(second_response_str.contains("Second Request")); -} + #[test] + fn test_keep_alive_connection() { + let mut response = HttpResponse::new( + HttpStatus::OK, + "Keep-Alive Test", + headers! { + "Connection" => "keep-alive", + "Content-Length" => "15" + }, + ); + + response.base().headers.insert( + "Keep-Alive".to_string(), + format!("timeout={}", KEEP_ALIVE_TTL.as_secs()), + ); + + let response_bytes = response.to_bytes(); + let response_str = String::from_utf8(response_bytes.clone()).unwrap(); + + assert!(response_str.contains("HTTP/1.1 200 OK")); + assert!(response_str.contains("Content-Length: 15")); + assert!(response_str.contains("Connection: keep-alive")); + assert!(response_str.contains("Keep-Alive: timeout=10")); + assert!(response_str.contains("Server: HTeaPot/")); + assert!(response_str.contains("Keep-Alive Test")); + + let mut second_response = HttpResponse::new( + HttpStatus::OK, + "Second Request", + headers! { + "Connection" => "keep-alive", + "Content-Length" => "14" // Length for "Second Request" + }, + ); + + second_response.base().headers.insert( + "Keep-Alive".to_string(), + format!("timeout={}", KEEP_ALIVE_TTL.as_secs()), + ); + + let second_response_bytes = second_response.to_bytes(); + let second_response_str = String::from_utf8(second_response_bytes.clone()).unwrap(); + + assert!(second_response_str.contains("HTTP/1.1 200 OK")); + assert!(second_response_str.contains("Content-Length: 14")); + assert!(response_str.contains("Connection: keep-alive")); + assert!(response_str.contains("Keep-Alive: timeout=10")); + assert!(response_str.contains("Server: HTeaPot/")); + assert!(second_response_str.contains("Second Request")); + } +} \ No newline at end of file diff --git a/src/hteapot/request.rs b/src/hteapot/request.rs index 4c7f6ef..4b8f939 100644 --- a/src/hteapot/request.rs +++ b/src/hteapot/request.rs @@ -1,7 +1,12 @@ // Written by Alberto Ruiz 2025-01-01 -// This module handles the request struct and a builder for it -// This implementation has some issues and fixes are required for security -// Refactor is recomended, but for now can work with the fixes +// +// This module defines the HTTP request structure and a streaming builder for parsing raw input. +// While the core functionality is usable, there are known limitations: +// - No support for chunked transfer encoding +// - Partial header validation +// - No URI normalization or encoding +// +// โš ๏ธ A full refactor is recommended before production use. use super::HttpMethod; use std::{cmp::min, collections::HashMap, net::TcpStream, str}; @@ -9,6 +14,9 @@ use std::{cmp::min, collections::HashMap, net::TcpStream, str}; const MAX_HEADER_SIZE: usize = 1024 * 16; const MAX_HEADER_COUNT: usize = 100; +/// Represents a parsed HTTP request. +/// +/// Contains method, path, optional query arguments, headers, body, and a stream (for low-level access). #[derive(Debug)] pub struct HttpRequest { pub method: HttpMethod, @@ -20,6 +28,7 @@ pub struct HttpRequest { } impl HttpRequest { + /// Creates a new HTTP request with the given method and path. pub fn new(method: HttpMethod, path: &str) -> Self { return HttpRequest { method, @@ -31,6 +40,7 @@ impl HttpRequest { }; } + /// Returns a blank default request (empty method/path/headers). pub fn default() -> Self { HttpRequest { method: HttpMethod::Other(String::new()), @@ -42,6 +52,7 @@ impl HttpRequest { } } + /// Returns a blank default request (empty method/path/headers). pub fn clone(&self) -> Self { return HttpRequest { method: self.method.clone(), @@ -53,10 +64,12 @@ impl HttpRequest { }; } + /// Attaches a raw TCP stream to this request. pub fn set_stream(&mut self, stream: TcpStream) { self.stream = Some(stream); } + /// Attempts to decode the body as UTF-8 and return it as text. pub fn text(&self) -> Option { if self.body.len() == 0 { return None; @@ -69,6 +82,9 @@ impl HttpRequest { } } +/// Builder for incrementally parsing a raw HTTP request. +/// +/// This is useful when reading from a stream (e.g., TCP) in chunks. pub struct HttpRequestBuilder { request: HttpRequest, buffer: Vec, @@ -79,6 +95,7 @@ pub struct HttpRequestBuilder { } impl HttpRequestBuilder { + /// Creates a new builder in the initial state. pub fn new() -> Self { return HttpRequestBuilder { request: HttpRequest { @@ -97,6 +114,7 @@ impl HttpRequestBuilder { }; } + /// Returns the built request if parsing is complete. pub fn get(&self) -> Option { if self.done { return Some(self.request.clone()); @@ -105,6 +123,7 @@ impl HttpRequestBuilder { } } + /// Reads bytes into the request body based on `Content-Length`. fn read_body_len(&mut self) -> Option<()> { let body_left = self.body_size.saturating_sub(self.request.body.len()); let to_take = min(body_left, self.buffer.len()); @@ -120,21 +139,28 @@ impl HttpRequestBuilder { } } - fn read_body_chunk(&mut self) -> Option<()> { + /// Placeholder for future support of chunked body parsing. + fn _read_body_chunk(&mut self) -> Option<()> { //TODO: this will support chunked body in the future todo!() } + /// Main entry point for reading the request body. fn read_body(&mut self) -> Option<()> { return self.read_body_len(); } + /// Feeds a chunk of bytes into the builder. + /// + /// This function may return an error if the header is too large or malformed. pub fn append(&mut self, chunk: Vec) -> Result<(), &'static str> { if !self.header_done && self.buffer.len() > MAX_HEADER_SIZE { return Err("Entity Too large"); } + let chunk_size = chunk.len(); self.buffer.extend(chunk); + if self.header_done { self.read_body(); return Ok(()); @@ -144,9 +170,10 @@ impl HttpRequestBuilder { return Err("Entity Too large"); } } + while let Some(pos) = self.buffer.windows(2).position(|w| w == b"\r\n") { let line = self.buffer.drain(..pos).collect::>(); - self.buffer.drain(..2); + self.buffer.drain(..2); // remove CRLF let line_str = match str::from_utf8(line.as_slice()) { Ok(v) => v.to_string(), @@ -154,6 +181,7 @@ impl HttpRequestBuilder { }; if self.request.path.is_empty() { + // This is the request line let parts: Vec<&str> = line_str.split_whitespace().collect(); if parts.len() < 2 { return Ok(()); @@ -162,6 +190,7 @@ impl HttpRequestBuilder { if parts.len() != 3 { return Err("Invalid method + path + version request"); } + self.request.method = HttpMethod::from_str(parts[0]); let path_parts: Vec<&str> = parts[1].split('?').collect(); self.request.path = path_parts[0].to_string(); @@ -180,14 +209,17 @@ impl HttpRequestBuilder { .collect(); } } else if !line_str.is_empty() { + // Header line if let Some((key, value)) = line_str.split_once(":") { //Check the number of headers, if the actual headers exceed that number //drop the connection if self.request.headers.len() > MAX_HEADER_COUNT { return Err("Header number exceed allowed"); } + let key = key.trim().to_lowercase(); let value = value.trim(); + if key == "content-length" { if self.request.headers.get("content-length").is_some() || self @@ -206,6 +238,7 @@ impl HttpRequestBuilder { .insert(key.to_string(), value.to_string()); } } else { + // Empty line = end of headers self.header_done = true; self.read_body(); return Ok(()); @@ -217,4 +250,6 @@ impl HttpRequestBuilder { #[cfg(test)] #[test] -fn basic_request() {} +fn basic_request() { + // Placeholder test โ€” add real body/header parsing test here. +} diff --git a/src/hteapot/response.rs b/src/hteapot/response.rs index dae9979..ee95a43 100644 --- a/src/hteapot/response.rs +++ b/src/hteapot/response.rs @@ -1,3 +1,12 @@ +//! HTTP response types for HTeaPot. +//! +//! Supports multiple types of responses: +//! - [`HttpResponse`] for standard fixed-size responses +//! - [`StreamedResponse`] for chunked transfer encoding +//! - [`EmptyHttpResponse`] as a sentinel or fallback +//! +//! All response types implement the [`HttpResponseCommon`] trait. + use super::HttpStatus; use super::{BUFFER_SIZE, VERSION}; use std::collections::{HashMap, VecDeque}; @@ -7,29 +16,35 @@ use std::sync::Arc; use std::thread; use std::thread::JoinHandle; +/// Basic HTTP status line + headers. pub struct BaseResponse { pub status: HttpStatus, pub headers: HashMap, } impl BaseResponse { + /// Converts the status + headers into a properly formatted HTTP header block. pub fn to_bytes(&mut self) -> Vec { let mut headers_text = String::new(); for (key, value) in self.headers.iter() { headers_text.push_str(&format!("{}: {}\r\n", key, value)); } + let response_header = format!( "HTTP/1.1 {} {}\r\n{}\r\n", self.status as u16, self.status.to_string(), headers_text ); + let mut response = Vec::new(); response.extend_from_slice(response_header.as_bytes()); response } } + +/// Represents a full HTTP response (headers + body). pub struct HttpResponse { base: BaseResponse, pub content: Vec, @@ -38,12 +53,19 @@ pub struct HttpResponse { index: usize, } +/// Trait shared by all response types (normal, streamed, etc.) pub trait HttpResponseCommon { + /// Returns a mutable reference to the base response (for status/headers). fn base(&mut self) -> &mut BaseResponse; + + /// Advances and returns the next chunk of the response body. fn next(&mut self) -> Result, IterError>; + + /// Advances and returns the next chunk of the response body. fn peek(&mut self) -> Result, IterError>; } +/// Error returned during response iteration. #[derive(Debug)] pub enum IterError { WouldBlock, @@ -51,6 +73,9 @@ pub enum IterError { } impl HttpResponse { + /// Creates a standard HTTP response with body and optional headers. + /// + /// Automatically sets `Content-Length` and `Server` headers. pub fn new>( status: HttpStatus, content: B, @@ -58,11 +83,13 @@ impl HttpResponse { ) -> Box { let mut headers = headers.unwrap_or(HashMap::new()); let content = content.as_ref(); + headers.insert("Content-Length".to_string(), content.len().to_string()); headers.insert( "Server".to_string(), format!("HTeaPot/{}", VERSION).to_string(), ); + Box::new(HttpResponse { base: BaseResponse { status, headers }, content: content.to_owned(), @@ -72,6 +99,7 @@ impl HttpResponse { }) } + /// Creates a raw response from raw bytes (used for proxy responses). pub fn new_raw(raw: Vec) -> Self { HttpResponse { base: BaseResponse { @@ -85,24 +113,29 @@ impl HttpResponse { } } + /// Returns true if this is a raw (proxy) response. pub fn is_raw(&self) -> bool { self.is_raw } + /// Serializes the entire response into a byte buffer. pub fn to_bytes(&mut self) -> Vec { if self.is_raw() { return self.raw.clone().unwrap(); } + let mut headers_text = String::new(); for (key, value) in self.base.headers.iter() { headers_text.push_str(&format!("{}: {}\r\n", key, value)); } + let response_header = format!( "HTTP/1.1 {} {}\r\n{}\r\n", self.base.status as u16, self.base.status.to_string(), headers_text ); + let mut response = Vec::new(); response.extend_from_slice(response_header.as_bytes()); response.append(&mut self.content); @@ -127,6 +160,7 @@ impl HttpResponseCommon for HttpResponse { if self.raw.is_none() { self.raw = Some(self.to_bytes()); } + let raw = self.raw.as_ref().unwrap(); let mut raw = raw.chunks(BUFFER_SIZE).skip(self.index); let byte_chunk = raw.next().ok_or(IterError::Finished)?.to_vec(); @@ -134,6 +168,7 @@ impl HttpResponseCommon for HttpResponse { } } +/// Dummy response used when nothing needs to be returned. pub struct EmptyHttpResponse {} impl EmptyHttpResponse {} @@ -141,6 +176,7 @@ impl HttpResponseCommon for EmptyHttpResponse { fn base(&mut self) -> &mut BaseResponse { panic!("Invalid state") } + fn next(&mut self) -> Result, IterError> { Err(IterError::Finished) } @@ -150,9 +186,14 @@ impl HttpResponseCommon for EmptyHttpResponse { } } +/// Sends response chunks in a `Transfer-Encoding: chunked` format. pub struct ChunkSender(Sender>); impl ChunkSender { + /// Sends a new chunk to the output stream. + /// + /// Prepends the size in hex followed by CRLF, then the chunk, then another CRLF. + // fn new(sender: Sender>) -> Self { // Self(sender) // } @@ -168,6 +209,9 @@ impl ChunkSender { // fn end(&self) -> Result<(), SendError>> {} } +/// Represents a streaming HTTP response using chunked transfer encoding. +/// +/// Runs the streaming action in a background thread. Chunks are sent via a channel. pub struct StreamedResponse { base: BaseResponse, receiver: Receiver>, @@ -177,22 +221,28 @@ pub struct StreamedResponse { } impl StreamedResponse { + /// Creates a new streamed response. The provided closure is run in a separate thread. + /// + /// The closure is given a `ChunkSender` to emit data. The response ends when the closure exits. pub fn new(action: impl Fn(ChunkSender) + Send + Sync + 'static) -> Box { let action = Arc::new(action); let (tx, rx) = mpsc::channel(); - let action_clon = action.clone(); + let mut base = BaseResponse { status: HttpStatus::OK, headers: HashMap::new(), }; + base.headers - .insert("Transfer-Encoding".to_string(), "chunked".to_string()); + .insert("Transfer-Encoding".to_string(), "chunked".to_string()); base.headers.insert( "Server".to_string(), format!("HTeaPot/{}", VERSION).to_string(), ); + let _ = tx.send(base.to_bytes()); let has_end = Arc::new(AtomicBool::new(false)); + let action_clon = action.clone(); let has_end_clone = has_end.clone(); let jh = thread::spawn(move || { @@ -201,6 +251,7 @@ impl StreamedResponse { let _ = tx.clone().send(b"0\r\n\r\n".to_vec()); has_end_clone.store(true, Ordering::SeqCst); }); + Box::new(StreamedResponse { base, has_end, @@ -219,6 +270,7 @@ impl HttpResponseCommon for StreamedResponse { fn base(&mut self) -> &mut BaseResponse { &mut self.base } + fn next(&mut self) -> Result, IterError> { self.peek() } diff --git a/src/hteapot/status.rs b/src/hteapot/status.rs index 4aec09a..f066a47 100644 --- a/src/hteapot/status.rs +++ b/src/hteapot/status.rs @@ -1,5 +1,19 @@ +//! HTTP status code definitions and helpers. +//! +//! This module defines the `HttpStatus` enum which maps common and uncommon +//! HTTP status codes to semantic Rust values. It includes conversions from +//! `u16` and methods to get the standard reason phrase. + +/// Represents an HTTP status code with a semantic variant name. +/// +/// This enum maps standard HTTP status codes (e.g., 200 OK, 404 Not Found) +/// to variants that make them easier to work with in Rust. +/// +/// Use [`HttpStatus::from_u16`] to convert from raw codes, +/// and [`HttpStatus::to_string`] to get the standard reason phrase. #[derive(Clone, Copy)] pub enum HttpStatus { + // 2xx Success OK = 200, Created = 201, Accepted = 202, @@ -8,15 +22,17 @@ pub enum HttpStatus { ResetContent = 205, PartialContent = 206, + // 3xx Redirection MultipleChoices = 300, MovedPermanently = 301, - Found = 302, // (Renombrado de MovedTemporarily) + Found = 302, // (Renamed from MovedTemporarily) SeeOther = 303, NotModified = 304, UseProxy = 305, TemporaryRedirect = 307, PermanentRedirect = 308, + // 4xx Client Error BadRequest = 400, Unauthorized = 401, PaymentRequired = 402, @@ -46,6 +62,7 @@ pub enum HttpStatus { TooManyRequests = 429, RequestHeaderFieldsTooLarge = 431, + // 5xx Server Error InternalServerError = 500, NotImplemented = 501, BadGateway = 502, @@ -60,6 +77,9 @@ pub enum HttpStatus { } impl HttpStatus { + /// Attempts to convert a `u16` status code into an `HttpStatus` enum variant. + /// + /// Returns an error if the code is not recognized. pub fn from_u16(status: u16) -> Result { match status { 200 => Ok(HttpStatus::OK), @@ -67,6 +87,9 @@ impl HttpStatus { 202 => Ok(HttpStatus::Accepted), 203 => Ok(HttpStatus::NonAuthoritativeInformation), 204 => Ok(HttpStatus::NoContent), + 205 => Ok(HttpStatus::ResetContent), + 206 => Ok(HttpStatus::PartialContent), + 300 => Ok(HttpStatus::MultipleChoices), 301 => Ok(HttpStatus::MovedPermanently), 302 => Ok(HttpStatus::Found), @@ -75,6 +98,7 @@ impl HttpStatus { 305 => Ok(HttpStatus::UseProxy), 307 => Ok(HttpStatus::TemporaryRedirect), 308 => Ok(HttpStatus::PermanentRedirect), + 400 => Ok(HttpStatus::BadRequest), 401 => Ok(HttpStatus::Unauthorized), 402 => Ok(HttpStatus::PaymentRequired), @@ -92,6 +116,7 @@ impl HttpStatus { 414 => Ok(HttpStatus::URITooLong), 415 => Ok(HttpStatus::UnsupportedMediaType), 416 => Ok(HttpStatus::RangeNotSatisfiable), + 417 => Ok(HttpStatus::ExpectationFailed), 418 => Ok(HttpStatus::IAmATeapot), 421 => Ok(HttpStatus::MisdirectedRequest), 422 => Ok(HttpStatus::UnprocessableEntity), @@ -102,6 +127,7 @@ impl HttpStatus { 428 => Ok(HttpStatus::PreconditionRequired), 429 => Ok(HttpStatus::TooManyRequests), 431 => Ok(HttpStatus::RequestHeaderFieldsTooLarge), + 500 => Ok(HttpStatus::InternalServerError), 501 => Ok(HttpStatus::NotImplemented), 502 => Ok(HttpStatus::BadGateway), @@ -113,37 +139,38 @@ impl HttpStatus { 508 => Ok(HttpStatus::LoopDetected), 510 => Ok(HttpStatus::NotExtended), 511 => Ok(HttpStatus::NetworkAuthenticationRequired), + _ => Err("Invalid HTTP status"), } } + /// Returns the standard reason phrase for this status code. + /// + /// For example: `HttpStatus::OK.to_string()` returns `"OK"`. pub fn to_string(&self) -> &str { match self { HttpStatus::OK => "OK", HttpStatus::Created => "Created", HttpStatus::Accepted => "Accepted", - HttpStatus::NoContent => "No Content", - HttpStatus::MovedPermanently => "Moved Permanently", - HttpStatus::Found => "Moved Temporarily", - HttpStatus::NotModified => "Not Modified", - HttpStatus::BadRequest => "Bad Request", - HttpStatus::Unauthorized => "Unauthorized", - HttpStatus::Forbidden => "Forbidden", - HttpStatus::NotFound => "Not Found", - HttpStatus::IAmATeapot => "I'm a teapot", - HttpStatus::InternalServerError => "Internal Server Error", - HttpStatus::NotImplemented => "Not Implemented", - HttpStatus::BadGateway => "Bad Gateway", - HttpStatus::ServiceUnavailable => "Service Unavailable", HttpStatus::NonAuthoritativeInformation => "Non Authoritative Information", + HttpStatus::NoContent => "No Content", HttpStatus::ResetContent => "Reset Content", - HttpStatus::PartialContent => "PartialContent", + HttpStatus::PartialContent => "Partial Content", + HttpStatus::MultipleChoices => "Multiple Choices", + HttpStatus::MovedPermanently => "Moved Permanently", + HttpStatus::Found => "Found", HttpStatus::SeeOther => "See Other", + HttpStatus::NotModified => "Not Modified", HttpStatus::UseProxy => "Use Proxy", HttpStatus::TemporaryRedirect => "Temporary Redirect", HttpStatus::PermanentRedirect => "Permanent Redirect", + + HttpStatus::BadRequest => "Bad Request", + HttpStatus::Unauthorized => "Unauthorized", HttpStatus::PaymentRequired => "Payment Required", + HttpStatus::Forbidden => "Forbidden", + HttpStatus::NotFound => "Not Found", HttpStatus::MethodNotAllowed => "Method Not Allowed", HttpStatus::NotAcceptable => "Not Acceptable", HttpStatus::ProxyAuthenticationRequired => "Proxy Authentication Required", @@ -157,6 +184,7 @@ impl HttpStatus { HttpStatus::UnsupportedMediaType => "Unsupported Media Type", HttpStatus::RangeNotSatisfiable => "Range Not Satisfiable", HttpStatus::ExpectationFailed => "Expectation Failed", + HttpStatus::IAmATeapot => "I'm a teapot", HttpStatus::MisdirectedRequest => "Misdirected Request", HttpStatus::UnprocessableEntity => "Unprocessable Entity", HttpStatus::Locked => "Locked", @@ -166,6 +194,11 @@ impl HttpStatus { HttpStatus::PreconditionRequired => "Precondition Required", HttpStatus::TooManyRequests => "Too Many Requests", HttpStatus::RequestHeaderFieldsTooLarge => "Request Header Fields Too Large", + + HttpStatus::InternalServerError => "Internal Server Error", + HttpStatus::NotImplemented => "Not Implemented", + HttpStatus::BadGateway => "Bad Gateway", + HttpStatus::ServiceUnavailable => "Service Unavailable", HttpStatus::GatewayTimeout => "Gateway Timeout", HttpStatus::HTTPVersionNotSupported => "HTTP Version Not Supported", HttpStatus::VariantAlsoNegotiates => "Variant Also Negotiates", diff --git a/src/logger.rs b/src/logger.rs index f61b19f..c7799ef 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -5,8 +5,6 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::fmt; use std::sync::Arc; - - /// Differnt log levels #[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Copy)] #[allow(dead_code)] @@ -32,6 +30,7 @@ impl fmt::Display for LogLevel { } } +/// A helper struct for generating formatted timestamps in `YYYY-MM-DD HH:MM:SS.sss` format. struct SimpleTime; impl SimpleTime { fn epoch_to_ymdhms(seconds: u64, nanos: u32) -> (i32, u32, u32, u32, u32, u32, u32) { @@ -89,6 +88,8 @@ impl SimpleTime { (year, month as u32 + 1, day as u32, hour, minute, second, millis) } + + /// Returns a formatted timestamp string for the current system time. pub fn get_current_timestamp() -> String { let now = SystemTime::now(); let since_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); @@ -103,7 +104,7 @@ impl SimpleTime { } } -// Log message with metadata +/// Internal message structure containing log metadata. struct LogMessage { timestamp: String, level: LogLevel, @@ -111,11 +112,16 @@ struct LogMessage { content: String, } +/// Core logger implementation running in a background thread. struct LoggerCore { tx: Sender, _thread: JoinHandle<()>, } +/// A non-blocking, multi-threaded logger with support for log levels and components. +/// +/// Logs are sent via a background thread to avoid blocking the main thread. +/// The logger buffers messages and flushes them periodically or based on buffer size. pub struct Logger { core: Arc, component: Arc, @@ -123,6 +129,7 @@ pub struct Logger { } impl Logger { + /// Creates a new logger with the given writer, minimum log level, and component name. pub fn new( mut writer: W, min_level: LogLevel, @@ -134,6 +141,7 @@ impl Logger { let mut buff = Vec::new(); let mut max_size = 100; let timeout = Duration::from_secs(1); + loop { let msg = rx.recv_timeout(timeout); match msg { @@ -148,6 +156,7 @@ impl Logger { Err(_) => break, } + // Flush if timeout or buffer threshold reached if last_flush.elapsed() >= timeout || buff.len() >= max_size { if !buff.is_empty() { if buff.len() >= max_size { @@ -178,7 +187,7 @@ impl Logger { } } - // New logger with different component but sharing same output + /// Creates a new logger instance with a different component name, but sharing same output. pub fn with_component(&self, component: &str) -> Logger { Logger { core: Arc::clone(&self.core), @@ -187,6 +196,7 @@ impl Logger { } } + /// Sends a log message with the given level and content. pub fn log(&self, level: LogLevel, content: String) { if level < self.min_level { return; @@ -198,10 +208,12 @@ impl Logger { component: (*self.component).clone(), content, }; + // Send the log message to the channel let _ = self.core.tx.send(log_msg); } + /// Logs a DEBUG-level message. pub fn debug(&self, content: String) { self.log(LogLevel::DEBUG, content); } @@ -233,6 +245,17 @@ impl Logger { } } +impl Clone for Logger { + fn clone(&self) -> Self { + Logger { + core: Arc::clone(&self.core), + component: Arc::clone(&self.component), + min_level: self.min_level, + } + } +} + + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 7aa096a..4b41751 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,39 @@ +//! # โ˜• Hteapot Web Server +//! +//! **Hteapot** is a fast, lightweight, and highly extensible HTTP server written in idiomatic Rust. +//! Designed for simplicity and performance, it supports: +//! +//! - ๐Ÿ” **Reverse Proxying** โ€” Forward requests to other servers based on custom routing rules +//! - ๐Ÿ“ **Static File Serving** โ€” Serve local files from a configurable directory +//! - โšก **In-Memory Caching** โ€” Reduce disk I/O with optional response caching +//! - ๐Ÿ“œ **Structured Logging** โ€” Toggle between file or console logging with fine-grained log levels +//! - ๐Ÿงต **Multithreading** โ€” Handle requests concurrently using a configurable thread pool +//! +//! ## Use Cases +//! +//! - Local development server +//! - Lightweight reverse proxy +//! - Static site deployment +//! - Embedded use in tools or microservices +//! +//! ## Entry Point +//! +//! This crate's primary entry point is the `main.rs` module. It sets up configuration, +//! logging, caching, and request routing via the [`Hteapot`](crate::hteapot::Hteapot) engine. +//! +//! ## Example +//! +//! ```sh +//! $ hteapot ./config.toml +//! ``` +//! +//! Or serve a single file quickly: +//! +//! ```sh +//! $ hteapot --serve ./index.html +//! ``` +//! +//! See the [`config`](crate::config) module for configuration options and structure. mod cache; mod config; pub mod hteapot; @@ -18,10 +54,28 @@ use std::time::Instant; const VERSION: &str = env!("CARGO_PKG_VERSION"); -// Safely join paths and ensure the result is within the root directory -// Try to canonicalize to resolve any '..' components -// Ensure the canonicalized path is still within the root directory -// Check if the path exists before canonicalizing +/// Attempts to safely join a root directory and a requested relative path. +/// +/// Ensures that the resulting path: +/// - Resolves symbolic links and `..` segments via `canonicalize` +/// - Remains within the bounds of the specified root directory +/// - Actually exists on disk +/// +/// This protects against directory traversal vulnerabilities, such as accessing +/// files outside of the intended root (e.g., `/etc/passwd`). +/// +/// # Arguments +/// * `root` - The root directory from which serving is allowed. +/// * `requested_path` - The path requested by the client (usually from the URL). +/// +/// # Returns +/// `Some(PathBuf)` if the resolved path exists and is within the root. `None` otherwise. +/// +/// # Example +/// ``` +/// let safe_path = safe_join_paths("/var/www", "/index.html"); +/// assert!(safe_path.unwrap().ends_with("index.html")); +/// ``` fn safe_join_paths(root: &str, requested_path: &str) -> Option { let root_path = Path::new(root).canonicalize().ok()?; let requested_full_path = root_path.join(requested_path.trim_start_matches("/")); @@ -39,6 +93,17 @@ fn safe_join_paths(root: &str, requested_path: &str) -> Option { } } +/// Determines whether a given HTTP request should be proxied based on the configuration. +/// +/// If a matching proxy rule is found in `config.proxy_rules`, the function rewrites the +/// request path and updates the `Host` header accordingly. +/// +/// # Arguments +/// * `config` - Server configuration containing proxy rules. +/// * `req` - The original HTTP request. +/// +/// # Returns +/// `Some((proxy_url, modified_request))` if the request should be proxied, otherwise `None`. fn is_proxy(config: &Config, req: HttpRequest) -> Option<(String, HttpRequest)> { for proxy_path in config.proxy_rules.keys() { let path_match = req.path.strip_prefix(proxy_path); @@ -61,22 +126,59 @@ fn is_proxy(config: &Config, req: HttpRequest) -> Option<(String, HttpRequest)> None } -// Change from &string to &PathBuf cos PathBuf explicitly represents a file system path as an owned buffer, -// making it clear that the data is intended to be a path rather than just any string. -// This reduces errors by enforcing the correct type for file system operations. -// Read more here: https://doc.rust-lang.org/std/path/index.html +/// Reads the content of a file from the filesystem. +/// +/// # Arguments +/// * `path` - A reference to a `PathBuf` representing the target file. +/// +/// # Returns +/// `Some(Vec)` if the file is read successfully, or `None` if an error occurs. +/// +/// # Notes +/// Uses `PathBuf` instead of `&str` to clearly express intent and reduce path handling bugs. +/// +/// # See Also +/// [`std::fs::read`](https://doc.rust-lang.org/std/fs/fn.read.html) fn serve_file(path: &PathBuf) -> Option> { let r = fs::read(path); if r.is_ok() { Some(r.unwrap()) } else { None } } +// +// Suggest to use .ok()? instead of manual unwrap/if is_ok for more idiomatic error handling: +// fn serve_file(path: &PathBuf) -> Option> { + // fs::read(path).ok() +// } +// +// + + +/// Main entry point of the Hteapot server. +/// +/// Handles command-line interface, config file parsing, optional file-serving mode, +/// logger initialization, and server startup. Incoming requests are processed via +/// proxy rules or served from local files with optional caching. +/// +/// # CLI Usage +/// - `hteapot config.toml` โ€“ Start with a full configuration file. +/// - `hteapot --serve ./file.html` โ€“ Serve a single file. +/// - `hteapot --help` or `--version` โ€“ Show usage info. +/// +/// This function initializes core components: +/// - Configuration (`Config`) +/// - Logging (`Logger`) +/// - Optional response caching +/// - HTTP server via [`Hteapot::new_threaded`](crate::hteapot::Hteapot::new_threaded) fn main() { + // Parse CLI args and handle --help / --version / --serve flags let args = std::env::args().collect::>(); if args.len() == 1 { println!("Hteapot {}", VERSION); println!("usage: {} ", args[0]); return; } + + // Initialize logger based on config or default to stdout let config = match args[1].as_str() { "--help" | "-h" => { println!("Hteapot {}", VERSION); @@ -115,67 +217,88 @@ fn main() { _ => config::Config::load_config(&args[1]), }; + // Determine if the server should proxy all requests let proxy_only = config.proxy_rules.get("/").is_some(); + + // Initialize the logger based on the config or default to stdout if the log file can't be created let logger = match config.log_file.clone() { Some(file_name) => { - let file = fs::File::create(file_name.clone()); - match file { - Ok(file) => Logger::new(file, LogLevel::INFO, "main"), + let file = fs::File::create(file_name.clone()); // Attempt to create the log file + match file { // If creating the file fails, log to stdout instead + Ok(file) => Logger::new(file, LogLevel::INFO, "main"), // If successful, use the file Err(e) => { println!("Failed to create log file: {:?}. Using stdout instead.", e); - Logger::new(io::stdout(), LogLevel::INFO, "main") + Logger::new(io::stdout(), LogLevel::INFO, "main") // Log to stdout } } } - None => Logger::new(io::stdout(), LogLevel::INFO, "main"), + None => Logger::new(io::stdout(), LogLevel::INFO, "main"), // If no log file is specified, use stdout }; - let cache: Mutex = Mutex::new(Cache::new(config.cache_ttl as u64)); + // Set up the cache with thread-safe locking + // The Mutex ensures that only one thread can access the cache at a time, + // preventing race conditions when reading and writing to the cache. + let cache: Mutex = Mutex::new(Cache::new(config.cache_ttl as u64)); // Initialize the cache with TTL + + // Create a new threaded HTTP server with the provided host, port, and number of threads let server = Hteapot::new_threaded(config.host.as_str(), config.port, config.threads); + logger.info(format!( "Server started at http://{}:{}", config.host, config.port - )); + )); // Log that the server has started + + // Log whether the cache is enabled based on the config setting if config.cache { logger.info("Cache Enabled".to_string()); } + + // If proxy-only mode is enabled, issue a warning that local paths won't be used if proxy_only { logger .warn("WARNING: All requests are proxied to /. Local paths won't be used.".to_string()); } - // Create component loggers + // Create separate loggers for each component (proxy, cache, and HTTP) + // This allows for more granular control over logging and better separation of concerns let proxy_logger = logger.with_component("proxy"); let cache_logger = logger.with_component("cache"); let http_logger = logger.with_component("http"); + // Start listening for HTTP requests server.listen(move |req| { - // SERVER CORE - // for each request - let start_time = Instant::now(); - let req_method = req.method.to_str(); - let req_path = req.path.clone(); + // SERVER CORE: For each incoming request, we handle it in this closure + let start_time = Instant::now(); // Track request processing time + let req_method = req.method.to_str(); // Get the HTTP method (e.g., GET, POST) + let req_path = req.path.clone(); // Get the requested path - http_logger.info(format!("Request {} {}", req.method.to_str(), req.path)); + // Log the incoming request method and path + http_logger.info(format!("Request {} {}", req_method, req.path)); + // Check if the request should be proxied (either because proxy-only mode is on, or it matches a rule) let is_proxy = is_proxy(&config, req.clone()); - if proxy_only || is_proxy.is_some() { - let (host, proxy_req) = is_proxy.unwrap(); + // If proxying is enabled or this request matches a proxy rule, handle it + let (host, proxy_req) = is_proxy.unwrap(); // Get the target host and modified request proxy_logger.info(format!( "Proxying request {} {} to {}", req_method, req_path, host )); + + + // Perform the proxy request (forward the request to the target server) let res = proxy_req.brew(host.as_str()); - let elapsed = start_time.elapsed(); + let elapsed = start_time.elapsed(); // Measure the time taken to process the proxy request if res.is_ok() { + // If the proxy request is successful, log the time taken and return the response let response = res.unwrap(); proxy_logger.info(format!( "Proxy request processed in {:.6}ms", - elapsed.as_secs_f64() * 1000.0 + elapsed.as_secs_f64() * 1000.0 // Log the time taken in milliseconds )); return response; } else { + // If the proxy request fails, log the error and return a 500 Internal Server Error proxy_logger.error(format!("Proxy request failed: {:?}", res.err())); return HttpResponse::new( HttpStatus::InternalServerError, @@ -185,84 +308,107 @@ fn main() { } } - // Safely resolve the requested path + // If the request is not a proxy request, resolve the requested path safely let safe_path_result = if req.path == "/" { - // Handle root path specially + // Special handling for the root "/" path let root_path = Path::new(&config.root).canonicalize(); if root_path.is_ok() { + // If the root path exists and is valid, try to join the index file let index_path = root_path.unwrap().join(&config.index); if index_path.exists() { - Some(index_path) + Some(index_path) // If index exists, return its path } else { - None + None // If no index exists, return None } } else { - None + None // If the root path is invalid, return None } } else { + // For any other path, resolve it safely using the `safe_join_paths` function safe_join_paths(&config.root, &req.path) }; - // Handle directory paths + // Handle the case where the resolved path is a directory let safe_path = match safe_path_result { Some(path) => { if path.is_dir() { + // If it's a directory, check for the index file in that directory let index_path = path.join(&config.index); if index_path.exists() { - index_path + index_path // If index exists, return its path } else { + // If no index file exists, log a warning and return a 404 response http_logger.warn(format!("Index file not found in directory: {}", req.path)); return HttpResponse::new(HttpStatus::NotFound, "Index not found", None); } } else { - path + path // If it's not a directory, just return the path } }, None => { + // If the path is invalid or access is denied, return a 404 response http_logger.warn(format!("Path not found or access denied: {}", req.path)); return HttpResponse::new(HttpStatus::NotFound, "Not found", None); } }; + // Determine the MIME type for the file based on its extension let mimetype = get_mime_tipe(&safe_path.to_string_lossy().to_string()); + + // Try to serve the file from the cache, or read it from disk if not cached let content: Option> = if config.cache { + // Lock the cache to ensure thread-safe access let mut cachee = cache.lock().expect("Error locking cache"); - let cache_start = Instant::now(); - let cache_key = req.path.clone(); - let mut r = cachee.get(cache_key.clone()); + let cache_start = Instant::now(); // Track cache operation time + let cache_key = req.path.clone(); // Use the request path as the cache key + let mut r = cachee.get(cache_key.clone()); // Try to get the content from cache if r.is_none() { + // If cache miss, read the file from disk and store it in cache cache_logger.debug(format!("cache miss for {}", cache_key)); r = serve_file(&safe_path); if r.is_some() { + // If the file is read successfully, add it to the cache cache_logger.info(format!("Adding {} to cache", cache_key)); cachee.set(cache_key, r.clone().unwrap()); } } else { + // If cache hit, log it cache_logger.debug(format!("cache hit for {}", cache_key)); } + // Log how long the cache operation took let cache_elapsed = cache_start.elapsed(); cache_logger.debug(format!( "Cache operation completed in {:.6}ยตs", cache_elapsed.as_micros() )); - r + r // Return the cached content (or None if not found) } else { + // If cache is disabled, read the file from disk serve_file(&safe_path) }; + // Log how long the request took to process let elapsed = start_time.elapsed(); http_logger.info(format!( "Request processed in {:.6}ms", - elapsed.as_secs_f64() * 1000.0 + elapsed.as_secs_f64() * 1000.0 // Log the time taken in milliseconds )); - + + // If content was found, return it with the appropriate headers, otherwise return a 404 match content { Some(c) => { - let headers = headers!("Content-Type" => mimetype, "X-Content-Type-Options" => "nosniff"); + // If content is found, create response with proper headers and a 200 OK status + let headers = headers!( + "Content-Type" => mimetype, + "X-Content-Type-Options" => "nosniff" + ); HttpResponse::new(HttpStatus::OK, c, headers) }, - None => HttpResponse::new(HttpStatus::NotFound, "Not found", None), + None => { + // If no content is found, return a 404 Not Found response + HttpResponse::new(HttpStatus::NotFound, "Not found", None) + }, } }); } \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs index 65561a4..8e56ca4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,12 +1,35 @@ use std::path::Path; +/// Returns the MIME type based on the file extension of a given path. +/// +/// This function maps common file extensions to their appropriate +/// `Content-Type` values for HTTP responses. +/// +/// If the extension is unrecognized or missing, it defaults to +/// `"application/octet-stream"` for safe binary delivery. +/// +/// # Arguments +/// +/// * `path` - A file path as a `String` from which to extract the extension. +/// +/// # Examples +/// +/// ``` +/// let mime = get_mime_tipe(&"file.html".to_string()); +/// assert_eq!(mime, "text/html; charset=utf-8"); +/// ``` pub fn get_mime_tipe(path: &String) -> String { let extension = Path::new(path.as_str()) .extension() .map(|ext| ext.to_str().unwrap_or("")) .unwrap_or(""); + + // Suggest using `to_str()` directly on the extension + // Alternative way to get the extension + // .and_then(|ext| ext.to_str()) let mimetipe = match extension { + // Text "html" | "htm" => "text/html; charset=utf-8", "js" => "text/javascript", "mjs" => "text/javascript",