diff --git a/Cargo.toml b/Cargo.toml index a238daf7..2d99eea9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "21.12.1" +version = "21.13.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -25,15 +25,15 @@ resolver = "2" members = ["compress", "constant", "type", "request", "core", "macros", "cli", "plugin/websocket"] [workspace.dependencies] -hyperlane = { path = ".", version = "21.12.1" } -http-type = { path = "type", version = "21.12.1" } -hyperlane-cli = { path = "cli", version = "21.12.1" } -hyperlane-core = { path = "core", version = "21.12.1" } -http-request = { path = "request", version = "21.12.1" } -http-compress = { path = "compress", version = "21.12.1" } -http-constant = { path = "constant", version = "21.12.1" } -hyperlane-macros = { path = "macros", version = "21.12.1" } -hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.12.1" } +hyperlane = { path = ".", version = "21.13.0" } +http-type = { path = "type", version = "21.13.0" } +hyperlane-cli = { path = "cli", version = "21.13.0" } +hyperlane-core = { path = "core", version = "21.13.0" } +http-request = { path = "request", version = "21.13.0" } +http-compress = { path = "compress", version = "21.13.0" } +http-constant = { path = "constant", version = "21.13.0" } +hyperlane-macros = { path = "macros", version = "21.13.0" } +hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.0" } hex = "0.4.3" md5 = "0.8.1" diff --git a/cli/src/config/struct.rs b/cli/src/config/struct.rs index deba00d7..c06037a2 100644 --- a/cli/src/config/struct.rs +++ b/cli/src/config/struct.rs @@ -1,16 +1,24 @@ use super::*; /// Parsed command line arguments -#[derive(Clone, Debug)] +/// +/// `hyperlane-cli` ships both a library and a `hyperlane` binary; the binary +/// is a separate crate that consumes this one, so the read path must stay +/// `pub` (§17.14 does not recall published API). +#[derive(Clone, Data, Debug)] pub struct Args { /// The command to execute pub command: CommandType, /// Project name for new command + #[get(type(clone))] pub project_name: Option, /// Template type for template command + #[get(type(clone))] pub template_type: Option, /// Model subtype for template command (only when template_type is Model) + #[get(type(clone))] pub model_sub_type: Option, /// Component name for template command + #[get(type(clone))] pub component_name: Option, } diff --git a/cli/src/main.rs b/cli/src/main.rs index d0ebe694..7541a8d1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -9,7 +9,7 @@ use std::process::exit; async fn main() { Logger::init(log::LevelFilter::Info); let args: Args = parse_args(); - match args.command { + match args.get_command() { CommandType::Watch => { if let Err(error) = execute_watch().await { log::error!("watch failed: {error}"); @@ -17,7 +17,7 @@ async fn main() { } } CommandType::New => { - if let Some(project_name) = args.project_name { + if let Some(project_name) = args.get_project_name() { if let Err(error) = execute_new(&project_name).await { log::error!("new failed: {error}"); exit(1); @@ -30,7 +30,7 @@ async fn main() { } } CommandType::Template => { - let template_type: TemplateType = match args.template_type { + let template_type: TemplateType = match args.get_template_type() { Some(tt) => tt, None => { log::error!( @@ -39,7 +39,7 @@ async fn main() { exit(1); } }; - let component_name: String = match args.component_name { + let component_name: String = match args.get_component_name() { Some(cn) => cn, None => { log::error!( @@ -48,12 +48,12 @@ async fn main() { exit(1); } }; - if template_type == TemplateType::Model && args.model_sub_type.is_none() { + if template_type == TemplateType::Model && args.get_model_sub_type().is_none() { log::error!("Error: Model type requires subtype (application|request|response)"); exit(1); } if let Err(error) = - execute_template(template_type, &component_name, args.model_sub_type).await + execute_template(template_type, &component_name, args.get_model_sub_type()).await { log::error!("template failed: {error}"); exit(1); diff --git a/cli/src/new/struct.rs b/cli/src/new/struct.rs index 529a46c2..af548111 100644 --- a/cli/src/new/struct.rs +++ b/cli/src/new/struct.rs @@ -1,5 +1,7 @@ +use super::*; + /// Configuration for creating a new project -#[derive(Clone, Debug)] +#[derive(Clone, Data, Debug)] pub struct NewProjectConfig { /// Name of the project to create pub project_name: String, diff --git a/cli/src/template/struct.rs b/cli/src/template/struct.rs index 0dbb2345..ecd96767 100644 --- a/cli/src/template/struct.rs +++ b/cli/src/template/struct.rs @@ -1,7 +1,7 @@ use super::*; /// Configuration for template generation -#[derive(Clone, Debug)] +#[derive(Clone, Data, Debug)] pub struct TemplateConfig { /// Type of template component to generate pub template_type: TemplateType, diff --git a/core/src/hook/struct.rs b/core/src/hook/struct.rs index 122aada5..7a810248 100644 --- a/core/src/hook/struct.rs +++ b/core/src/hook/struct.rs @@ -45,7 +45,7 @@ pub struct ServerControlHook { /// the application exits. #[debug(skip)] #[set(pub(crate))] - pub(super) wait_hook: ServerControlHookHandler<()>, + pub(crate) wait_hook: ServerControlHookHandler<()>, /// A hook that, when called, initiates a graceful shutdown of the server. /// This will stop the server from accepting new connections and allow existing ones /// to complete. diff --git a/core/tests/server/struct.rs b/core/tests/server/struct.rs index c8d56ace..a397a267 100644 --- a/core/tests/server/struct.rs +++ b/core/tests/server/struct.rs @@ -3,13 +3,13 @@ use super::*; pub(crate) struct TestSendRoute; pub(crate) struct TaskPanicHook { - pub(crate) response_body: String, - pub(crate) content_type: String, + pub(super) response_body: String, + pub(super) content_type: String, } pub(crate) struct RequestErrorHook { - pub(crate) response_status_code: ResponseStatusCode, - pub(crate) response_body: String, + pub(super) response_status_code: ResponseStatusCode, + pub(super) response_body: String, } pub(crate) struct RequestMiddleware { @@ -21,9 +21,9 @@ pub(crate) struct UpgradeMiddleware; pub(crate) struct ResponseMiddleware; pub(crate) struct RootRoute { - pub(crate) response_body: String, - pub(crate) cookie1: String, - pub(crate) cookie2: String, + pub(super) response_body: String, + pub(super) cookie1: String, + pub(super) cookie2: String, } pub(crate) struct SseRoute; @@ -31,7 +31,7 @@ pub(crate) struct SseRoute; pub(crate) struct WebsocketRoute; pub(crate) struct DynamicRoute { - pub(crate) params: RouteParams, + pub(super) params: RouteParams, } pub(crate) struct GetAllRoutes; diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 74b68323..75a16d65 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -3216,7 +3216,7 @@ pub fn try_get_request_query(attr: TokenStream, item: TokenStream) -> TokenStrea /// /// #[prologue_macros( /// request_query("test" => request_query), -/// response_body(&format!("request query: {request_query}")), +/// response_body(&format!("request query: {request_query:?}")), /// send /// )] /// async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { Status::Continue } diff --git a/plugin/websocket/Cargo.toml b/plugin/websocket/Cargo.toml index de94dbfb..4c5f7e70 100644 --- a/plugin/websocket/Cargo.toml +++ b/plugin/websocket/Cargo.toml @@ -14,4 +14,5 @@ exclude.workspace = true [dependencies] hyperlane = { workspace = true } +lombok-macros = { workspace = true } tokio-broadcast = { workspace = true } diff --git a/plugin/websocket/src/impl.rs b/plugin/websocket/src/impl.rs index a680b78d..706292d1 100644 --- a/plugin/websocket/src/impl.rs +++ b/plugin/websocket/src/impl.rs @@ -390,46 +390,6 @@ where self } - /// Returns a mutable reference to the stream served by this configuration. - /// - /// # Returns - /// - /// - `&mut Stream` - A mutable reference to the stream. - #[inline(always)] - pub fn get_stream(&mut self) -> &mut Stream { - self.stream - } - - /// Retrieves a reference to the context associated with this configuration. - /// - /// # Returns - /// - /// - `&mut Context` - A reference to the context object. - #[inline(always)] - pub fn get_context(&mut self) -> &mut Context { - self.context - } - - /// Retrieves the capacity configured for the broadcast sender. - /// - /// # Returns - /// - /// - `Capacity` - The capacity. - #[inline(always)] - pub fn get_capacity(&self) -> Capacity { - self.capacity - } - - /// Retrieves a reference to the broadcast type configured for this WebSocket. - /// - /// # Returns - /// - /// - `&BroadcastType` - A reference to the broadcast type object. - #[inline(always)] - pub fn get_broadcast_type(&self) -> &BroadcastType { - &self.broadcast_type - } - /// Sets the connected hook handler. /// /// This hook is executed when the WebSocket connection is established. @@ -561,46 +521,6 @@ where self.closed_hook = Hook::factory::(); self } - - /// Retrieves a reference to the connected hook handler. - /// - /// # Returns - /// - /// - `&ServerHookHandler` - A reference to the connected hook handler. - #[inline(always)] - pub fn get_connected_hook(&self) -> &ServerHookHandler { - &self.connected_hook - } - - /// Retrieves a reference to the request hook handler. - /// - /// # Returns - /// - /// - `&ServerHookHandler` - A reference to the request hook handler. - #[inline(always)] - pub fn get_request_hook(&self) -> &ServerHookHandler { - &self.request_hook - } - - /// Retrieves a reference to the sended hook handler. - /// - /// # Returns - /// - /// - `&ServerHookHandler` - A reference to the sended hook handler. - #[inline(always)] - pub fn get_sended_hook(&self) -> &ServerHookHandler { - &self.sended_hook - } - - /// Retrieves a reference to the closed hook handler. - /// - /// # Returns - /// - /// - `&ServerHookHandler` - A reference to the closed hook handler. - #[inline(always)] - pub fn get_closed_hook(&self) -> &ServerHookHandler { - &self.closed_hook - } } impl WebSocket { @@ -616,19 +536,6 @@ impl WebSocket { Self::default() } - /// Returns a shared reference to the internal broadcast map. - /// - /// Hand-written accessor: the `WebSocket` struct does not derive the lombok - /// `Data` macro, and §17.3 forbids reading `self.broadcast_map` directly. - /// - /// # Returns - /// - /// - `&BroadcastMap>` - A shared reference to the internal broadcast map. - #[inline(always)] - pub fn get_broadcast_map(&self) -> &BroadcastMap> { - &self.broadcast_map - } - /// Subscribes to a broadcast type or inserts a new one if it doesn't exist. /// /// # Type Parameters diff --git a/plugin/websocket/src/lib.rs b/plugin/websocket/src/lib.rs index eb0ae48a..8924ccb1 100644 --- a/plugin/websocket/src/lib.rs +++ b/plugin/websocket/src/lib.rs @@ -27,5 +27,6 @@ use { tokio::sync::broadcast::{Receiver, error::SendError}, *, }, + lombok_macros::*, tokio_broadcast::*, }; diff --git a/plugin/websocket/src/struct.rs b/plugin/websocket/src/struct.rs index 97e44733..22a8bece 100644 --- a/plugin/websocket/src/struct.rs +++ b/plugin/websocket/src/struct.rs @@ -4,7 +4,7 @@ use super::*; /// /// This struct manages broadcast capabilities and holds the internal broadcast map /// responsible for handling message distribution to various WebSocket connections. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Data, Debug, Default)] pub struct WebSocket { /// The internal broadcast map. /// @@ -21,36 +21,46 @@ pub struct WebSocket { /// # Type Parameters /// /// - `B`: The type used for broadcast keys, which must implement `BroadcastTypeTrait`. +#[derive(Data)] pub struct WebSocketConfig<'a, B: BroadcastTypeTrait> { /// The underlying stream for this WebSocket connection. + #[set(skip)] pub(super) stream: &'a mut Stream, /// The Hyperlane context. /// /// This context is associated with this WebSocket connection. + #[set(skip)] pub(super) context: &'a mut Context, /// The capacity. /// /// This is the capacity of the broadcast sender channel. + #[get(type(copy))] + #[set(skip)] pub(super) capacity: Capacity, /// The broadcast type. /// /// This defines the type of broadcast this WebSocket connection will participate in /// (point-to-point or point-to-group). + #[set(skip)] pub(super) broadcast_type: BroadcastType, /// The connected hook handler. /// /// This hook is executed when the WebSocket connection is established. + #[set(skip)] pub(super) connected_hook: ServerHookHandler, /// The request hook handler. /// /// This hook is executed when a new request is received on the WebSocket. + #[set(skip)] pub(super) request_hook: ServerHookHandler, /// The sended hook handler. /// /// This hook is executed after a message has been successfully sent over the WebSocket. + #[set(skip)] pub(super) sended_hook: ServerHookHandler, /// The closed hook handler. /// /// This hook is executed when the WebSocket connection is closed. + #[set(skip)] pub(super) closed_hook: ServerHookHandler, } diff --git a/plugin/websocket/tests/websocket/struct.rs b/plugin/websocket/tests/websocket/struct.rs index d9c3e5e4..e7755c5f 100644 --- a/plugin/websocket/tests/websocket/struct.rs +++ b/plugin/websocket/tests/websocket/struct.rs @@ -1,13 +1,13 @@ use super::*; pub(crate) struct TaskPanicHook { - pub(crate) response_body: String, - pub(crate) content_type: String, + pub(super) response_body: String, + pub(super) content_type: String, } pub(crate) struct RequestErrorHook { - pub(crate) response_status_code: ResponseStatusCode, - pub(crate) response_body: String, + pub(super) response_status_code: ResponseStatusCode, + pub(super) response_body: String, } pub(crate) struct RequestMiddleware { @@ -17,36 +17,36 @@ pub(crate) struct RequestMiddleware { pub(crate) struct UpgradeHook; pub(crate) struct ConnectedHook { - pub(crate) receiver_count: ReceiverCount, - pub(crate) data: String, - pub(crate) group_broadcast_type: BroadcastType, - pub(crate) private_broadcast_type: BroadcastType, + pub(super) receiver_count: ReceiverCount, + pub(super) data: String, + pub(super) group_broadcast_type: BroadcastType, + pub(super) private_broadcast_type: BroadcastType, } pub(crate) struct SendedHook { - pub(crate) msg: String, + pub(super) msg: String, } pub(crate) struct GroupChatRequestHook { - pub(crate) body: RequestBody, - pub(crate) receiver_count: ReceiverCount, + pub(super) body: RequestBody, + pub(super) receiver_count: ReceiverCount, } pub(crate) struct GroupClosedHook { - pub(crate) body: String, - pub(crate) receiver_count: ReceiverCount, + pub(super) body: String, + pub(super) receiver_count: ReceiverCount, } pub(crate) struct GroupChat; pub(crate) struct PrivateChatRequestHook { - pub(crate) body: RequestBody, - pub(crate) receiver_count: ReceiverCount, + pub(super) body: RequestBody, + pub(super) receiver_count: ReceiverCount, } pub(crate) struct PrivateClosedHook { - pub(crate) body: String, - pub(crate) receiver_count: ReceiverCount, + pub(super) body: String, + pub(super) receiver_count: ReceiverCount, } pub(crate) struct PrivateChat; diff --git a/request/src/common/impl.rs b/request/src/common/impl.rs index ed52299e..e3f3f246 100644 --- a/request/src/common/impl.rs +++ b/request/src/common/impl.rs @@ -17,15 +17,6 @@ impl Body { } } - /// View body as `&[u8]`. - /// - /// # Returns - /// - /// - `&[u8]` - The raw body bytes. - pub fn get_bytes_ref(&self) -> &[u8] { - &self.bytes - } - /// View body as `&[u8]`. /// /// # Returns @@ -62,7 +53,7 @@ impl Display for Body { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self.as_str() { Some(s) => f.write_str(s), - None => f.write_str(&format!("{:?}", self.bytes)), + None => f.write_str(&format!("{:?}", self.get_bytes())), } } } diff --git a/request/src/common/struct.rs b/request/src/common/struct.rs index 83b84666..b29cd6f1 100644 --- a/request/src/common/struct.rs +++ b/request/src/common/struct.rs @@ -13,5 +13,5 @@ use super::*; #[derive(Clone, Debug, Default, Eq, Getter, PartialEq, Serialize)] pub struct Body { /// Raw body bytes. - pub bytes: Vec, + pub(super) bytes: Vec, } diff --git a/request/src/request/config/impl.rs b/request/src/request/config/impl.rs deleted file mode 100644 index fbe7ef83..00000000 --- a/request/src/request/config/impl.rs +++ /dev/null @@ -1,164 +0,0 @@ -use super::*; - -impl RequestConfig { - /// Sets the `buffer_size` field. - /// - /// # Arguments - /// - /// - `usize` - The new buffer size in bytes. - /// - /// # Returns - /// - /// - `&mut Self` - A mutable reference to the updated configuration. - pub fn set_buffer_size(&mut self, v: usize) -> &mut Self { - self.buffer_size = v; - self - } - - /// Gets the `buffer_size` field. - /// - /// # Returns - /// - /// - `usize` - The buffer size in bytes. - pub fn get_buffer_size(&self) -> usize { - self.buffer_size - } - - /// Sets the `timeout` field. - /// - /// # Arguments - /// - /// - `u64` - The new request timeout in seconds. - /// - /// # Returns - /// - /// - `&mut Self` - A mutable reference to the updated configuration. - pub fn set_timeout(&mut self, v: u64) -> &mut Self { - self.timeout = v; - self - } - - /// Gets the `timeout` field. - /// - /// # Returns - /// - /// - `u64` - The request timeout in seconds. - pub fn get_timeout(&self) -> u64 { - self.timeout - } - - /// Sets the `max_redirect_times` field. - /// - /// # Arguments - /// - /// - `usize` - The maximum number of redirects to follow. - /// - /// # Returns - /// - /// - `&mut Self` - A mutable reference to the updated configuration. - pub fn set_max_redirect_times(&mut self, v: usize) -> &mut Self { - self.max_redirect_times = v; - self - } - - /// Gets the `max_redirect_times` field. - /// - /// # Returns - /// - /// - `usize` - The maximum number of redirects to follow. - pub fn get_max_redirect_times(&self) -> usize { - self.max_redirect_times - } - - /// Sets the `http_version` field. - /// - /// # Arguments - /// - /// - `HttpVersion` - The HTTP version to negotiate. - /// - /// # Returns - /// - /// - `&mut Self` - A mutable reference to the updated configuration. - pub fn set_http_version(&mut self, v: HttpVersion) -> &mut Self { - self.http_version = v; - self - } - - /// Gets the `http_version` field. - /// - /// # Returns - /// - /// - `&HttpVersion` - A reference to the negotiated HTTP version. - pub fn get_http_version(&self) -> &HttpVersion { - &self.http_version - } - - /// Sets the `redirect` field. - /// - /// # Arguments - /// - /// - `bool` - Whether redirects should be followed. - /// - /// # Returns - /// - /// - `&mut Self` - A mutable reference to the updated configuration. - pub fn set_redirect(&mut self, v: bool) -> &mut Self { - self.redirect = v; - self - } - - /// Gets the `redirect` field. - /// - /// # Returns - /// - /// - `bool` - Whether redirects are followed. - pub fn get_redirect(&self) -> bool { - self.redirect - } - - /// Sets the `decode` field. - /// - /// # Arguments - /// - /// - `bool` - Whether the response body should be decoded. - /// - /// # Returns - /// - /// - `&mut Self` - A mutable reference to the updated configuration. - pub fn set_decode(&mut self, v: bool) -> &mut Self { - self.decode = v; - self - } - - /// Gets the `decode` field. - /// - /// # Returns - /// - /// - `bool` - Whether the response body is decoded. - pub fn get_decode(&self) -> bool { - self.decode - } - - /// Sets the `proxy` field. - /// - /// # Arguments - /// - /// - `Option` - The proxy to route requests through, or `None` for a direct connection. - /// - /// # Returns - /// - /// - `&mut Self` - A mutable reference to the updated configuration. - pub fn set_proxy(&mut self, v: Option) -> &mut Self { - self.proxy = v; - self - } - - /// Gets the `proxy` field. - /// - /// # Returns - /// - /// - `&Option` - A reference to the configured proxy, or `None` for a direct connection. - pub fn get_proxy(&self) -> &Option { - &self.proxy - } -} diff --git a/request/src/request/config/mod.rs b/request/src/request/config/mod.rs index 4dfc79f4..1a7bc417 100644 --- a/request/src/request/config/mod.rs +++ b/request/src/request/config/mod.rs @@ -1,4 +1,3 @@ -mod r#impl; mod r#struct; pub use r#struct::*; diff --git a/request/src/request/config/struct.rs b/request/src/request/config/struct.rs index 90157ed5..58a757c5 100644 --- a/request/src/request/config/struct.rs +++ b/request/src/request/config/struct.rs @@ -2,27 +2,32 @@ use super::*; /// Configuration for an outgoing HTTP request. /// -/// Field-by-field setters are generated by `lombok-macros` (`#[set]` / -/// `#[set(type(...))]`), aligning with `hyperlane-core`'s `ServerConfig` / -/// `RequestConfig` style — see `hyperlane-standards §8`. +/// Every accessor is generated by `lombok-macros` (`#[derive(Data)]`) — no +/// hand-written `get_*` / `set_*` (rust-standards §17.3 / §17.11). /// /// For most use cases, prefer the fluent setters on /// [`crate::RequestBuilder`]. This type is the underlying data passed to /// `HttpRequest` and is also returned by [`HttpRequest::config`]. -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Data, Debug, Default, Eq, PartialEq)] pub struct RequestConfig { /// Per-read buffer size in bytes. - pub buffer_size: usize, + #[get(type(copy))] + pub(crate) buffer_size: usize, /// Request timeout in milliseconds. - pub timeout: u64, + #[get(type(copy))] + pub(super) timeout: u64, /// Maximum number of redirects to follow (0 = no redirect). - pub max_redirect_times: usize, + #[get(type(copy))] + pub(super) max_redirect_times: usize, /// HTTP version (`HttpVersion::Http1_1` / `HttpVersion::Http2`). - pub http_version: HttpVersion, + pub(super) http_version: HttpVersion, /// Whether to follow redirects automatically. - pub redirect: bool, + #[get(type(copy))] + pub(super) redirect: bool, /// Whether to automatically decode response bodies (gzip / deflate / br). - pub decode: bool, + #[get(type(copy))] + pub(super) decode: bool, /// Optional proxy configuration. `None` means direct connection. - pub proxy: Option, + #[get(type(clone))] + pub(super) proxy: Option, } diff --git a/request/src/request/http_request/const.rs b/request/src/request/http_request/const.rs index 67b108d0..a9280a31 100644 --- a/request/src/request/http_request/const.rs +++ b/request/src/request/http_request/const.rs @@ -42,3 +42,9 @@ pub(crate) const HEADER_TERMINATOR: &str = "\r\n\r\n"; /// HTTP request-line method token for POST. pub(crate) const HTTP_METHOD_POST: &str = "POST"; + +/// The `": "` separator written between a header name and its value. +pub(crate) const HEADER_VALUE_SEPARATOR: &[u8] = b": "; + +/// The carriage-return / line-feed pair terminating an HTTP header line. +pub(crate) const HTTP_CARRIAGE_RETURN_LINE_FEED: &[u8] = b"\r\n"; diff --git a/request/src/request/http_request/impl.rs b/request/src/request/http_request/impl.rs index adfdc7cd..8831763d 100644 --- a/request/src/request/http_request/impl.rs +++ b/request/src/request/http_request/impl.rs @@ -28,7 +28,7 @@ impl HttpRequest { /// /// - `Result` - The parsed URL components, or a `RequestError` when the URL is malformed. pub(crate) fn parse_url(&self) -> Result { - HttpUrlComponents::parse(self.get_url_ref()) + HttpUrlComponents::parse(self.get_url()) .map_err(|e: ::http_type::HttpUrlError| RequestError::Request(e.to_string())) } @@ -39,8 +39,8 @@ impl HttpRequest { /// - `String` - The request path, with the query string appended when present. pub(crate) fn full_path(&self) -> String { let url_obj: HttpUrlComponents = self.parse_url().unwrap_or_default(); - let query: String = url_obj.query.unwrap_or_default(); - let path: String = url_obj.path.unwrap_or_default(); + let query: String = url_obj.get_query().unwrap_or_default(); + let path: String = url_obj.get_path().unwrap_or_default(); if query.is_empty() { path } else { @@ -58,7 +58,7 @@ impl HttpRequest { /// /// - `String` - The lower-cased protocol name. pub(crate) fn protocol_lower(config: &RequestConfig) -> String { - config.http_version.to_string().to_ascii_lowercase() + config.get_http_version().to_string().to_ascii_lowercase() } /// Build the wire-format header bytes, with `Host`, `Content-Length`, @@ -72,11 +72,11 @@ impl HttpRequest { /// /// - `Vec` - The wire-format request header bytes. pub(crate) fn header_bytes(&self, body_length: usize) -> Vec { - let mut header: HashMap = self.get_headers(); + let mut header: HashMap = self.get_headers().clone(); let host_value: String = self .parse_url() .ok() - .and_then(|u: ::http_type::HttpUrlComponents| u.host.clone()) + .and_then(|u: ::http_type::HttpUrlComponents| u.get_host().clone()) .unwrap_or_default(); let content_length_value: String = body_length.to_string(); if !Self::header_has_key(&header, HOST) { @@ -98,7 +98,7 @@ impl HttpRequest { let mut out: Vec = Vec::with_capacity(estimated_size); for (key, value) in &header { out.extend_from_slice(key.as_bytes()); - out.extend_from_slice(b": "); + out.extend_from_slice(HEADER_VALUE_SEPARATOR); out.extend_from_slice(value.as_bytes()); out.extend_from_slice(HTTP_BR_BYTES); } @@ -137,7 +137,7 @@ impl HttpRequest { .to_lowercase() .parse::() .unwrap_or_default() - .get_body_string(&String::from_utf8_lossy(self.get_body_ref().as_slice())) + .get_body_string(&String::from_utf8_lossy(self.get_body().as_slice())) .into_bytes(), None => Vec::new(), } @@ -150,8 +150,8 @@ impl HttpRequest { /// - `RequestResult` - The parsed HTTP response, or a `RequestError` on failure. pub(crate) fn send_sync(&mut self) -> RequestResult { let url_obj: HttpUrlComponents = self.parse_url()?; - let host: String = url_obj.host.clone().unwrap_or_default(); - let port: u16 = self.resolve_port(url_obj.port.unwrap_or_default()); + let host: String = url_obj.get_host().clone().unwrap_or_default(); + let port: u16 = self.resolve_port(url_obj.get_port().unwrap_or_default()); let mut stream: BoxReadWrite = self.open_sync_stream(host, port)?; let method: Method = self.get_method(); if method.is_get() { @@ -170,8 +170,8 @@ impl HttpRequest { /// - `RequestResult` - The parsed HTTP response, or a `RequestError` on failure. async fn send_async_impl(&mut self) -> RequestResult { let url_obj: HttpUrlComponents = self.parse_url()?; - let host: String = url_obj.host.clone().unwrap_or_default(); - let port: u16 = self.resolve_port(url_obj.port.unwrap_or_default()); + let host: String = url_obj.get_host().clone().unwrap_or_default(); + let port: u16 = self.resolve_port(url_obj.get_port().unwrap_or_default()); let mut stream: BoxAsyncReadWrite = self.open_async_stream(host, port).await?; let method: Method = self.get_method(); if method.is_get() { @@ -196,7 +196,7 @@ impl HttpRequest { if port != 0 { return port; } - let protocol: String = Self::protocol_lower(self.get_config_ref()); + let protocol: String = Self::protocol_lower(self.get_config()); Protocol::get_port(&protocol) } @@ -206,7 +206,7 @@ impl HttpRequest { /// /// - `bool` - `true` when the configured protocol is `https`, `false` otherwise. fn is_https(&self) -> bool { - Self::protocol_lower(self.get_config_ref()) == HTTPS_LOWERCASE + Self::protocol_lower(self.get_config()) == HTTPS_LOWERCASE } // ---------- sync stream plumbing ---------- @@ -222,10 +222,10 @@ impl HttpRequest { /// /// - `Result` - The connected stream, TLS-wrapped for HTTPS, or a `RequestError` on failure. fn open_sync_stream(&self, host: String, port: u16) -> Result { - if let Some(proxy) = &self.get_config_ref().proxy { + if let Some(proxy) = &self.get_config().get_proxy() { return self.open_sync_proxy_stream(host, port, proxy); } - let timeout: Duration = Duration::from_millis(self.get_config_ref().timeout); + let timeout: Duration = Duration::from_millis(self.get_config().get_timeout()); let tcp: TcpStream = TcpStream::connect((host.clone(), port)) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; tcp.set_read_timeout(Some(timeout)) @@ -233,7 +233,7 @@ impl HttpRequest { tcp.set_write_timeout(Some(timeout)) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; if self.is_https() { - let roots: RootCertStore = self.get_tmp_ref().get_root_cert().clone(); + let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); @@ -262,7 +262,7 @@ impl HttpRequest { ) -> Result { let path: String = self.full_path(); let header_bytes: Vec = self.header_bytes(0); - let version: String = self.get_config_ref().http_version.to_string(); + let version: String = self.get_config().get_http_version().to_string(); let request: Vec = build_http_request("GET", path, header_bytes, None, version); stream .write_all(&request) @@ -287,7 +287,7 @@ impl HttpRequest { let body_bytes: Vec = self.body_bytes(); let path: String = self.full_path(); let header_bytes: Vec = self.header_bytes(body_bytes.len()); - let version: String = self.get_config_ref().http_version.to_string(); + let version: String = self.get_config().get_http_version().to_string(); let request: Vec = build_http_request( HTTP_METHOD_POST, path, @@ -315,7 +315,7 @@ impl HttpRequest { &mut self, stream: &mut BoxReadWrite, ) -> Result { - let buffer_size: usize = self.get_config_ref().buffer_size; + let buffer_size: usize = self.get_config().buffer_size; let mut buffer: Vec = vec![0u8; buffer_size]; let mut response_bytes: Vec = Vec::with_capacity(buffer_size.max(8192)); let mut headers_done: bool = false; @@ -325,7 +325,7 @@ impl HttpRequest { let mut is_chunked: bool = false; let version_bytes: Vec = self .config - .http_version + .get_http_version() .to_string() .to_ascii_lowercase() .into_bytes(); @@ -377,9 +377,9 @@ impl HttpRequest { response_bytes.extend_from_slice(&decoded); } let mut response: HttpResponse = HttpResponse::from_bytes(&response_bytes); - if !self.get_config_ref().redirect || redirect_url.is_none() { - if self.get_config_ref().decode { - response = response.decode(self.get_config_ref().buffer_size); + if !self.get_config().get_redirect() || redirect_url.is_none() { + if self.get_config().get_decode() { + response = response.decode(self.get_config().buffer_size); } return Ok(response); } @@ -401,19 +401,21 @@ impl HttpRequest { /// /// - `Result` - The response of the redirect target, or a `RequestError` when redirects are disabled, looping, or exhausted. fn handle_redirect(&mut self, url: String) -> Result { - if !self.get_config_ref().redirect { + if !self.get_config().get_redirect() { return Err(RequestError::Request(REDIRECT_NOT_ENABLED.to_string())); } - if self.get_tmp_ref().get_visit_url().contains(&url) { + if self.get_tmp().get_visit_url().contains(&url) { return Err(RequestError::Request(REDIRECT_URL_DEAD_LOOP.to_string())); } - self.get_tmp_mut().get_mut_visit_url().insert(url.clone()); - if self.get_config_ref().max_redirect_times == 0 { + self.get_mut_tmp().get_mut_visit_url().insert(url.clone()); + if self.get_config().get_max_redirect_times() == 0 { return Err(RequestError::Request( MAX_REDIRECT_TIMES_EXCEEDED.to_string(), )); } - self.get_config_mut().max_redirect_times -= 1; + let remaining: usize = self.get_config().get_max_redirect_times(); + self.get_mut_config() + .set_max_redirect_times(remaining.saturating_sub(1)); self.set_url(url); self.send_sync() } @@ -432,7 +434,7 @@ impl HttpRequest { while pos < body_bytes.len() { let chunk_size_end: usize = match body_bytes[pos..] .windows(2) - .position(|w: &[u8]| w == b"\r\n") + .position(|w: &[u8]| w == HTTP_CARRIAGE_RETURN_LINE_FEED) { Some(p) => pos + p, None => return false, @@ -479,7 +481,7 @@ impl HttpRequest { host: String, port: u16, ) -> Result { - if let Some(proxy) = &self.get_config_ref().proxy { + if let Some(proxy) = &self.get_config().get_proxy() { return self.open_async_proxy_stream(host, port, proxy).await; } let tcp: http_type::tokio::net::TcpStream = @@ -487,7 +489,7 @@ impl HttpRequest { .await .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; if self.is_https() { - let roots: RootCertStore = self.get_tmp_ref().get_root_cert().clone(); + let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); @@ -519,7 +521,7 @@ impl HttpRequest { ) -> Result { let path: String = self.full_path(); let header_bytes: Vec = self.header_bytes(0); - let version: String = self.get_config_ref().http_version.to_string(); + let version: String = self.get_config().get_http_version().to_string(); let request: Vec = build_http_request("GET", path, header_bytes, None, version); stream .write_all(&request) @@ -548,7 +550,7 @@ impl HttpRequest { let body_bytes: Vec = self.body_bytes(); let path: String = self.full_path(); let header_bytes: Vec = self.header_bytes(body_bytes.len()); - let version: String = self.get_config_ref().http_version.to_string(); + let version: String = self.get_config().get_http_version().to_string(); let request: Vec = build_http_request( HTTP_METHOD_POST, path, @@ -580,7 +582,7 @@ impl HttpRequest { &mut self, stream: &mut BoxAsyncReadWrite, ) -> Result { - let buffer_size: usize = self.get_config_ref().buffer_size; + let buffer_size: usize = self.get_config().buffer_size; let mut buffer: Vec = vec![0u8; buffer_size]; let mut response_bytes: Vec = Vec::with_capacity(buffer_size.max(8192)); let mut headers_done: bool = false; @@ -590,7 +592,7 @@ impl HttpRequest { let mut is_chunked: bool = false; let version_bytes: Vec = self .config - .http_version + .get_http_version() .to_string() .to_ascii_lowercase() .into_bytes(); @@ -643,9 +645,9 @@ impl HttpRequest { response_bytes.extend_from_slice(&decoded); } let mut response: HttpResponse = HttpResponse::from_bytes(&response_bytes); - if !self.get_config_ref().redirect || redirect_url.is_none() { - if self.get_config_ref().decode { - response = response.decode(self.get_config_ref().buffer_size); + if !self.get_config().get_redirect() || redirect_url.is_none() { + if self.get_config().get_decode() { + response = response.decode(self.get_config().buffer_size); } return Ok(response); } @@ -667,19 +669,21 @@ impl HttpRequest { /// /// - `Result` - The response of the redirect target, or a `RequestError` when redirects are disabled, looping, or exhausted. async fn handle_redirect_async(&mut self, url: String) -> Result { - if !self.get_config_ref().redirect { + if !self.get_config().get_redirect() { return Err(RequestError::Request(REDIRECT_NOT_ENABLED.to_string())); } - if self.get_tmp_ref().get_visit_url().contains(&url) { + if self.get_tmp().get_visit_url().contains(&url) { return Err(RequestError::Request(REDIRECT_URL_DEAD_LOOP.to_string())); } - self.get_tmp_mut().get_mut_visit_url().insert(url.clone()); - if self.get_config_ref().max_redirect_times == 0 { + self.get_mut_tmp().get_mut_visit_url().insert(url.clone()); + if self.get_config().get_max_redirect_times() == 0 { return Err(RequestError::Request( MAX_REDIRECT_TIMES_EXCEEDED.to_string(), )); } - self.get_config_mut().max_redirect_times -= 1; + let remaining: usize = self.get_config().get_max_redirect_times(); + self.get_mut_config() + .set_max_redirect_times(remaining.saturating_sub(1)); self.set_url(url); Box::pin(self.send_async()).await } @@ -703,7 +707,7 @@ impl HttpRequest { target_port: u16, proxy: &Proxy, ) -> Result { - match proxy.proxy_type { + match *proxy.get_proxy_type() { ProxyType::Http | ProxyType::Https => { self.open_sync_http_proxy(target_host, target_port, proxy) } @@ -728,19 +732,19 @@ impl HttpRequest { target_port: u16, proxy: &Proxy, ) -> Result { - let timeout: Duration = Duration::from_millis(self.get_config_ref().timeout); - let tcp: TcpStream = TcpStream::connect((proxy.host.clone(), proxy.port)) + let timeout: Duration = Duration::from_millis(self.get_config().get_timeout()); + let tcp: TcpStream = TcpStream::connect((proxy.get_host().clone(), *proxy.get_port())) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; tcp.set_read_timeout(Some(timeout)) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; tcp.set_write_timeout(Some(timeout)) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; - let mut proxy_stream: BoxReadWrite = if proxy.proxy_type == ProxyType::Https { - let roots: RootCertStore = self.get_tmp_ref().get_root_cert().clone(); + let mut proxy_stream: BoxReadWrite = if proxy.get_proxy_type() == &ProxyType::Https { + let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); - let dns: ServerName<'static> = ServerName::try_from(proxy.host.clone()) + let dns: ServerName<'static> = ServerName::try_from(proxy.get_host().clone()) .map_err(|e: InvalidDnsNameError| RequestError::Request(e.to_string()))?; let session: ClientConnection = ClientConnection::new(Arc::new(tls_cfg), dns) .map_err(|e: rustls::Error| RequestError::Request(e.to_string()))?; @@ -748,7 +752,8 @@ impl HttpRequest { } else { Box::new(tcp) }; - let connect_request: String = if let (Some(u), Some(p)) = (&proxy.username, &proxy.password) + let connect_request: String = if let (Some(u), Some(p)) = + (proxy.get_username(), proxy.get_password()) { let auth: String = format!("{u}:{p}"); let encoded: String = crate::utils::base64_encode(auth.as_bytes()); @@ -786,7 +791,7 @@ impl HttpRequest { Vec::new() }; if self.is_https() { - let roots: RootCertStore = self.get_tmp_ref().get_root_cert().clone(); + let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); @@ -817,18 +822,19 @@ impl HttpRequest { target_port: u16, proxy: &Proxy, ) -> Result { - let timeout: Duration = Duration::from_millis(self.get_config_ref().timeout); - let mut tcp: TcpStream = TcpStream::connect((proxy.host.clone(), proxy.port)) + let timeout: Duration = Duration::from_millis(self.get_config().get_timeout()); + let mut tcp: TcpStream = TcpStream::connect((proxy.get_host().clone(), *proxy.get_port())) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; tcp.set_read_timeout(Some(timeout)) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; tcp.set_write_timeout(Some(timeout)) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; - let auth_methods: Vec = if proxy.username.is_some() && proxy.password.is_some() { - vec![0x05, 0x02, 0x00, 0x02] - } else { - vec![0x05, 0x01, 0x00] - }; + let auth_methods: Vec = + if proxy.get_username().is_some() && proxy.get_password().is_some() { + vec![0x05, 0x02, 0x00, 0x02] + } else { + vec![0x05, 0x01, 0x00] + }; tcp.write_all(&auth_methods) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; let mut resp: [u8; 2] = [0u8; 2]; @@ -840,7 +846,7 @@ impl HttpRequest { match resp[1] { 0x00 => {} 0x02 => { - let (Some(u), Some(p)) = (&proxy.username, &proxy.password) else { + let (Some(u), Some(p)) = (proxy.get_username(), proxy.get_password()) else { return Err(RequestError::Request(INTERNAL_SERVER_ERROR.to_string())); }; let mut auth_req: Vec = vec![0x01]; @@ -902,7 +908,7 @@ impl HttpRequest { _ => return Err(RequestError::Request(INTERNAL_SERVER_ERROR.to_string())), } if self.is_https() { - let roots: RootCertStore = self.get_tmp_ref().get_root_cert().clone(); + let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); @@ -936,7 +942,7 @@ impl HttpRequest { target_port: u16, proxy: &Proxy, ) -> Result { - match proxy.proxy_type { + match *proxy.get_proxy_type() { ProxyType::Http | ProxyType::Https => { self.open_async_http_proxy(target_host, target_port, proxy) .await @@ -965,17 +971,19 @@ impl HttpRequest { target_port: u16, proxy: &Proxy, ) -> Result { - let tcp: http_type::tokio::net::TcpStream = - http_type::tokio::net::TcpStream::connect((proxy.host.clone(), proxy.port)) - .await - .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; - let mut proxy_stream: BoxAsyncReadWrite = if proxy.proxy_type == ProxyType::Https { - let roots: RootCertStore = self.get_tmp_ref().get_root_cert().clone(); + let tcp: http_type::tokio::net::TcpStream = http_type::tokio::net::TcpStream::connect(( + proxy.get_host().clone(), + *proxy.get_port(), + )) + .await + .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; + let mut proxy_stream: BoxAsyncReadWrite = if proxy.get_proxy_type() == &ProxyType::Https { + let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); let connector: TlsConnector = TlsConnector::from(Arc::new(tls_cfg)); - let dns: ServerName<'static> = ServerName::try_from(proxy.host.clone()) + let dns: ServerName<'static> = ServerName::try_from(proxy.get_host().clone()) .map_err(|e: InvalidDnsNameError| RequestError::Request(e.to_string()))?; let tls: tokio_rustls::client::TlsStream = connector .connect(dns, tcp) @@ -985,7 +993,8 @@ impl HttpRequest { } else { Box::new(tcp) }; - let connect_request: String = if let (Some(u), Some(p)) = (&proxy.username, &proxy.password) + let connect_request: String = if let (Some(u), Some(p)) = + (proxy.get_username(), proxy.get_password()) { let auth: String = format!("{u}:{p}"); let encoded: String = crate::utils::base64_encode(auth.as_bytes()); @@ -1026,7 +1035,7 @@ impl HttpRequest { Vec::new() }; if self.is_https() { - let roots: RootCertStore = self.get_tmp_ref().get_root_cert().clone(); + let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); @@ -1061,15 +1070,17 @@ impl HttpRequest { target_port: u16, proxy: &Proxy, ) -> Result { - let mut tcp: http_type::tokio::net::TcpStream = - http_type::tokio::net::TcpStream::connect((proxy.host.clone(), proxy.port)) - .await - .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; - let auth_methods: Vec = if proxy.username.is_some() && proxy.password.is_some() { - vec![0x05, 0x02, 0x00, 0x02] - } else { - vec![0x05, 0x01, 0x00] - }; + let mut tcp: http_type::tokio::net::TcpStream = http_type::tokio::net::TcpStream::connect( + (proxy.get_host().clone(), *proxy.get_port()), + ) + .await + .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; + let auth_methods: Vec = + if proxy.get_username().is_some() && proxy.get_password().is_some() { + vec![0x05, 0x02, 0x00, 0x02] + } else { + vec![0x05, 0x01, 0x00] + }; tcp.write_all(&auth_methods) .await .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; @@ -1083,7 +1094,7 @@ impl HttpRequest { match resp[1] { 0x00 => {} 0x02 => { - let (Some(u), Some(p)) = (&proxy.username, &proxy.password) else { + let (Some(u), Some(p)) = (proxy.get_username(), proxy.get_password()) else { return Err(RequestError::Request(INTERNAL_SERVER_ERROR.to_string())); }; let mut auth_req: Vec = vec![0x01u8]; @@ -1153,7 +1164,7 @@ impl HttpRequest { _ => return Err(RequestError::Request(INTERNAL_SERVER_ERROR.to_string())), } if self.is_https() { - let roots: RootCertStore = self.get_tmp_ref().get_root_cert().clone(); + let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); @@ -1304,123 +1315,6 @@ impl HttpRequest { self } - /// Get a copy of the HTTP method. - /// - /// # Returns - /// - /// - `Method` - A clone of the configured HTTP method. - pub fn get_method(&self) -> Method { - self.method.clone() - } - - /// Get a clone of the URL string. - /// - /// # Returns - /// - /// - `String` - A clone of the configured URL. - pub fn get_url(&self) -> String { - self.url.clone() - } - - /// Get a reference to the URL string. - /// - /// # Returns - /// - /// - `&str` - A borrowed slice of the configured URL. - pub fn get_url_ref(&self) -> &str { - self.url.as_str() - } - - /// Get a clone of the request headers map. - /// - /// # Returns - /// - /// - `HashMap` - A clone of the configured header map. - pub fn get_headers(&self) -> HashMap { - self.headers.clone() - } - - /// Get a reference to the request headers map. - /// - /// # Returns - /// - /// - `&HashMap` - A borrowed reference to the configured header map. - pub fn get_headers_ref(&self) -> &HashMap { - &self.headers - } - - /// Returns a mutable reference to the headers map. - /// - /// # Returns - /// - /// - `&mut HashMap` - The mutable headers map. - pub fn get_headers_mut(&mut self) -> &mut HashMap { - &mut self.headers - } - - /// Get a clone of the body. - /// - /// # Returns - /// - /// - `Body` - A clone of the configured body. - pub fn get_body(&self) -> Body { - self.body.clone() - } - - /// Get a reference to the body. - /// - /// # Returns - /// - /// - `&Body` - A borrowed reference to the configured body. - pub fn get_body_ref(&self) -> &Body { - &self.body - } - - /// Get a clone of the request config. - /// - /// # Returns - /// - /// - `RequestConfig` - A clone of the configured request options. - pub fn get_config(&self) -> RequestConfig { - self.config.clone() - } - - /// Get a reference to the request config. - /// - /// # Returns - /// - /// - `&RequestConfig` - A borrowed reference to the configured request options. - pub fn get_config_ref(&self) -> &RequestConfig { - &self.config - } - - /// Get a mutable reference to the request config. - /// - /// # Returns - /// - /// - `&mut RequestConfig` - A mutable reference to the configured request options. - pub fn get_config_mut(&mut self) -> &mut RequestConfig { - &mut self.config - } - - /// Get a reference to the internal scratch `Tmp`. - /// - /// # Returns - /// - /// - `&Tmp` - A borrowed reference to the internal redirect-tracking state. - pub(crate) fn get_tmp_ref(&self) -> &Tmp { - &self.tmp - } - - /// Get a mutable reference to the internal scratch `Tmp`. - /// - /// # Returns - /// - /// - `&mut Tmp` - A mutable reference to the internal redirect-tracking state. - pub(crate) fn get_tmp_mut(&mut self) -> &mut Tmp { - &mut self.tmp - } - /// Normalize a header key to lowercase so `set_header` / /// `remove_header` lookups are case-insensitive. /// diff --git a/request/src/request/http_request/struct.rs b/request/src/request/http_request/struct.rs index ed66ce0f..0cb44b17 100644 --- a/request/src/request/http_request/struct.rs +++ b/request/src/request/http_request/struct.rs @@ -10,9 +10,10 @@ use super::*; /// `HttpRequest` is constructed either via [`crate::RequestBuilder`] or /// directly from its public fields, then sent via [`HttpRequest::send`] /// (sync) or [`HttpRequest::send_async`]. -#[derive(Clone, Debug, Default, GetterMut)] +#[derive(Clone, Debug, Default, Getter, GetterMut)] pub struct HttpRequest { /// HTTP method (`Method::Get` / `Method::Post` / ...). + #[get(type(clone))] #[get_mut(skip)] pub method: Method, /// Target URL (string form, parsed on send). @@ -24,10 +25,8 @@ pub struct HttpRequest { #[get_mut(skip)] pub body: Body, /// Per-request config: timeout, redirects, proxy, decode, etc. - #[get_mut(skip)] pub config: RequestConfig, /// Internal scratch: redirect-loop tracking + TLS root store. /// Private; only `HttpRequest` methods touch it. - #[get_mut(skip)] - pub(crate) tmp: Tmp, + pub tmp: Tmp, } diff --git a/request/src/request/proxy/struct.rs b/request/src/request/proxy/struct.rs index 42c1c336..1c2177be 100644 --- a/request/src/request/proxy/struct.rs +++ b/request/src/request/proxy/struct.rs @@ -24,8 +24,10 @@ pub struct Proxy { /// Proxy server port. pub port: u16, /// Optional username for proxy auth. + #[get(type(clone))] pub username: Option, /// Optional password for proxy auth. + #[get(type(clone))] pub password: Option, } @@ -33,13 +35,13 @@ pub struct Proxy { /// pre-read bytes that are returned before delegating to the inner stream. #[derive(Data)] pub struct ProxyTunnelStream { - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] pub(super) inner: BoxAsyncReadWrite, - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] pub(super) pre_read_data: Vec, } @@ -47,12 +49,12 @@ pub struct ProxyTunnelStream { /// pre-read bytes that are returned before delegating to the inner stream. #[derive(Data)] pub struct SyncProxyTunnelStream { - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] pub(super) inner: BoxReadWrite, - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] pub(super) pre_read_data: Vec, } diff --git a/request/src/request/request_builder/impl.rs b/request/src/request/request_builder/impl.rs index 2683e575..fc123c75 100644 --- a/request/src/request/request_builder/impl.rs +++ b/request/src/request/request_builder/impl.rs @@ -1,15 +1,6 @@ use super::*; impl RequestBuilder { - /// Returns the underlying request mutably. - /// - /// # Returns - /// - /// - `&mut HttpRequest` - The mutable request being built. - pub fn get_request_mut(&mut self) -> &mut HttpRequest { - &mut self.request - } - /// Create an empty builder (defaults: GET, no headers, default config). pub fn new() -> Self { Self::default() @@ -189,7 +180,7 @@ impl RequestBuilder { /// /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn timeout(&mut self, ms: u64) -> &mut Self { - self.get_mut_request().get_config_mut().set_timeout(ms); + self.get_mut_request().get_mut_config().set_timeout(ms); self } @@ -203,7 +194,7 @@ impl RequestBuilder { /// /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn buffer_size(&mut self, n: usize) -> &mut Self { - self.get_mut_request().get_config_mut().set_buffer_size(n); + self.get_mut_request().get_mut_config().set_buffer_size(n); self } @@ -214,7 +205,7 @@ impl RequestBuilder { /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn http1_1_only(&mut self) -> &mut Self { self.get_mut_request() - .get_config_mut() + .get_mut_config() .set_http_version(HttpVersion::Http1_1); self } @@ -226,7 +217,7 @@ impl RequestBuilder { /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn http2_only(&mut self) -> &mut Self { self.get_mut_request() - .get_config_mut() + .get_mut_config() .set_http_version(HttpVersion::Http2); self } @@ -237,7 +228,7 @@ impl RequestBuilder { /// /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn redirect(&mut self) -> &mut Self { - self.get_mut_request().get_config_mut().set_redirect(true); + self.get_mut_request().get_mut_config().set_redirect(true); self } @@ -247,7 +238,7 @@ impl RequestBuilder { /// /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn no_redirect(&mut self) -> &mut Self { - self.get_mut_request().get_config_mut().set_redirect(false); + self.get_mut_request().get_mut_config().set_redirect(false); self } @@ -262,7 +253,7 @@ impl RequestBuilder { /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn max_redirect_times(&mut self, n: usize) -> &mut Self { self.get_mut_request() - .get_config_mut() + .get_mut_config() .set_max_redirect_times(n); self } @@ -273,7 +264,7 @@ impl RequestBuilder { /// /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn decode(&mut self) -> &mut Self { - self.get_mut_request().get_config_mut().set_decode(true); + self.get_mut_request().get_mut_config().set_decode(true); self } @@ -283,7 +274,7 @@ impl RequestBuilder { /// /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn no_decode(&mut self) -> &mut Self { - self.get_mut_request().get_config_mut().set_decode(false); + self.get_mut_request().get_mut_config().set_decode(false); self } @@ -302,7 +293,7 @@ impl RequestBuilder { /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn proxy(&mut self, proxy: Proxy) -> &mut Self { self.get_mut_request() - .get_config_mut() + .get_mut_config() .set_proxy(Some(proxy)); self } @@ -313,7 +304,7 @@ impl RequestBuilder { /// /// - `&mut Self` - A mutable reference to this builder for chaining. pub fn no_proxy(&mut self) -> &mut Self { - self.get_mut_request().get_config_mut().set_proxy(None); + self.get_mut_request().get_mut_config().set_proxy(None); self } diff --git a/request/src/request/request_builder/struct.rs b/request/src/request/request_builder/struct.rs index 1bbd5077..80a7f181 100644 --- a/request/src/request/request_builder/struct.rs +++ b/request/src/request/request_builder/struct.rs @@ -30,8 +30,5 @@ use super::*; /// ``` #[derive(Clone, Data, Debug, Default)] pub struct RequestBuilder { - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub(crate) request: HttpRequest, + pub request: HttpRequest, } diff --git a/request/src/request/tmp/impl.rs b/request/src/request/tmp/impl.rs new file mode 100644 index 00000000..dda153b7 --- /dev/null +++ b/request/src/request/tmp/impl.rs @@ -0,0 +1,18 @@ +use super::*; + +impl Default for Tmp { + /// Builds a default temporary store seeded with the bundled TLS server roots. + /// + /// # Returns + /// + /// - `Tmp`: a store with an empty visited-URL set and the bundled server roots. + #[inline(always)] + fn default() -> Self { + Self { + visit_url: HashSet::new(), + root_cert: RootCertStore { + roots: TLS_SERVER_ROOTS.to_vec(), + }, + } + } +} diff --git a/request/src/request/tmp/mod.rs b/request/src/request/tmp/mod.rs index 07036db3..5a079a81 100644 --- a/request/src/request/tmp/mod.rs +++ b/request/src/request/tmp/mod.rs @@ -1,3 +1,4 @@ +mod r#impl; mod r#struct; pub(crate) use r#struct::*; diff --git a/request/src/request/tmp/struct.rs b/request/src/request/tmp/struct.rs index 9431d86b..2a97385d 100644 --- a/request/src/request/tmp/struct.rs +++ b/request/src/request/tmp/struct.rs @@ -16,22 +16,5 @@ pub struct Tmp { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] - pub(crate) root_cert: RootCertStore, -} - -impl Default for Tmp { - /// Builds a default temporary store seeded with the bundled TLS server roots. - /// - /// # Returns - /// - /// - `Tmp`: a store with an empty visited-URL set and the bundled server roots. - #[inline(always)] - fn default() -> Self { - Self { - visit_url: HashSet::new(), - root_cert: RootCertStore { - roots: TLS_SERVER_ROOTS.to_vec(), - }, - } - } + pub(super) root_cert: RootCertStore, } diff --git a/request/tests/body/fn.rs b/request/tests/body/fn.rs index 86f35140..cb8f2224 100644 --- a/request/tests/body/fn.rs +++ b/request/tests/body/fn.rs @@ -3,7 +3,7 @@ use super::*; #[test] fn test_body_empty_has_no_bytes() { let body: Body = Body::empty(); - assert!(body.get_bytes_ref().is_empty()); + assert!(body.get_bytes().as_slice().is_empty()); assert!(body.as_slice().is_empty()); assert!(body.get_bytes().is_empty()); assert_eq!(body.as_str(), Some("")); @@ -13,7 +13,7 @@ fn test_body_empty_has_no_bytes() { fn test_body_default_equals_empty() { let body: Body = Body::default(); assert_eq!(body, Body::empty()); - assert!(body.get_bytes_ref().is_empty()); + assert!(body.get_bytes().as_slice().is_empty()); } #[test] @@ -21,15 +21,18 @@ fn test_body_from_bytes_accepts_str_and_vec() { let from_str: Body = Body::from_bytes("hi"); let from_vec: Body = Body::from_bytes(vec![b'h', b'i']); assert_eq!(from_str, from_vec); - assert_eq!(from_str.get_bytes_ref(), from_vec.get_bytes_ref()); + assert_eq!( + from_str.get_bytes().as_slice(), + from_vec.get_bytes().as_slice() + ); assert_eq!(from_str.as_str(), Some("hi")); - assert_eq!(from_str.get_bytes_ref().len(), 2usize); + assert_eq!(from_str.get_bytes().as_slice().len(), 2usize); } #[test] fn test_body_from_bytes_accepts_slice() { let body: Body = Body::from_bytes(&[1u8, 2, 3][..]); - assert_eq!(body.get_bytes_ref(), &[1u8, 2, 3]); + assert_eq!(body.get_bytes().as_slice(), &[1u8, 2, 3]); assert_eq!(body.as_str(), Some("\u{1}\u{2}\u{3}")); } @@ -38,7 +41,7 @@ fn test_body_as_str_is_none_for_invalid_utf8() { let body: Body = Body::from_bytes(vec![0xffu8, 0xfe]); assert_eq!(body.as_str(), None); assert_eq!(body.as_slice().len(), 2usize); - assert_eq!(body.get_bytes_ref(), &[0xffu8, 0xfe]); + assert_eq!(body.get_bytes().as_slice(), &[0xffu8, 0xfe]); } #[test] @@ -58,7 +61,7 @@ fn test_body_clone_is_equal_and_independent() { let body: Body = Body::from_bytes("abc"); let cloned: Body = body.clone(); assert_eq!(body, cloned); - assert_eq!(cloned.get_bytes_ref(), b"abc"); + assert_eq!(cloned.get_bytes().as_slice(), b"abc"); } #[test] diff --git a/request/tests/builder/fn.rs b/request/tests/builder/fn.rs index 1cdc3803..d5e6dc00 100644 --- a/request/tests/builder/fn.rs +++ b/request/tests/builder/fn.rs @@ -4,10 +4,10 @@ use super::*; fn test_builder_new_builds_empty_request() { let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.build(); - assert_eq!(request.get_url_ref(), ""); + assert_eq!(request.get_url(), ""); assert!(request.get_method().is_unknown()); - assert!(request.get_headers_ref().is_empty()); - assert!(request.get_body_ref().get_bytes_ref().is_empty()); + assert!(request.get_headers().is_empty()); + assert!(request.get_body().get_bytes().as_slice().is_empty()); } #[test] @@ -15,7 +15,7 @@ fn test_builder_get_sets_method_and_url() { let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.get("http://get.test/").build(); assert!(request.get_method().is_get()); - assert_eq!(request.get_url_ref(), "http://get.test/"); + assert_eq!(request.get_url(), "http://get.test/"); } #[test] @@ -23,7 +23,7 @@ fn test_builder_post_sets_method_and_url() { let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.post("http://post.test/").build(); assert!(request.get_method().is_post()); - assert_eq!(request.get_url_ref(), "http://post.test/"); + assert_eq!(request.get_url(), "http://post.test/"); } #[test] @@ -35,7 +35,7 @@ fn test_builder_method_and_url_overrides_shortcuts() { .url("http://second.test/") .build(); assert!(request.get_method() == Method::Put); - assert_eq!(request.get_url_ref(), "http://second.test/"); + assert_eq!(request.get_url(), "http://second.test/"); } #[test] @@ -46,7 +46,7 @@ fn test_builder_header_normalizes_key() { .header("Accept", "application/json") .build(); assert_eq!( - request.get_headers_ref().get("accept").map(String::as_str), + request.get_headers().get("accept").map(String::as_str), Some("application/json") ); } @@ -58,13 +58,13 @@ fn test_builder_headers_inserts_every_pair() { headers.insert("B".to_string(), "2".to_string()); let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.get("http://a.test/").headers(headers).build(); - assert_eq!(request.get_headers_ref().len(), 2usize); + assert_eq!(request.get_headers().len(), 2usize); assert_eq!( - request.get_headers_ref().get("a").map(String::as_str), + request.get_headers().get("a").map(String::as_str), Some("1") ); assert_eq!( - request.get_headers_ref().get("b").map(String::as_str), + request.get_headers().get("b").map(String::as_str), Some("2") ); } @@ -78,30 +78,30 @@ fn test_builder_remove_and_clear_headers() { .header("B", "2") .remove_header("a") .build(); - assert_eq!(request.get_headers_ref().len(), 1usize); - assert!(request.get_headers_ref().get("a").is_none()); + assert_eq!(request.get_headers().len(), 1usize); + assert!(request.get_headers().get("a").is_none()); let mut second: RequestBuilder = RequestBuilder::new(); let cleared: HttpRequest = second .get("http://a.test/") .header("A", "1") .clear_headers() .build(); - assert!(cleared.get_headers_ref().is_empty()); + assert!(cleared.get_headers().is_empty()); } #[test] fn test_builder_body_accepts_raw_bytes() { let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.post("http://a.test/").body(vec![1u8, 2, 3]).build(); - assert_eq!(request.get_body_ref().get_bytes_ref(), &[1u8, 2, 3]); + assert_eq!(request.get_body().get_bytes().as_slice(), &[1u8, 2, 3]); } #[test] fn test_builder_body_text_stores_utf8_bytes() { let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.post("http://a.test/").body_text("héllo").build(); - assert_eq!(request.get_body_ref().as_str(), Some("héllo")); - assert_eq!(request.get_body_ref().get_bytes_ref().len(), 6usize); + assert_eq!(request.get_body().as_str(), Some("héllo")); + assert_eq!(request.get_body().get_bytes().as_slice().len(), 6usize); } #[test] @@ -109,7 +109,7 @@ fn test_builder_body_json_serialises_value() { let body: serde_json::Value = json!({ "code": 1 }); let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.post("http://a.test/").body_json(&body).build(); - assert_eq!(request.get_body_ref().as_str(), Some("{\"code\":1}")); + assert_eq!(request.get_body().as_str(), Some("{\"code\":1}")); } #[test] @@ -122,7 +122,7 @@ fn test_builder_body_json_replaces_previous_body() { .body_json(&first) .body_json(&second) .build(); - assert_eq!(request.get_body_ref().as_str(), Some("{\"b\":2}")); + assert_eq!(request.get_body().as_str(), Some("{\"b\":2}")); } #[test] @@ -133,45 +133,39 @@ fn test_builder_timeout_and_buffer_size() { .timeout(6000u64) .buffer_size(8192usize) .build(); - assert_eq!(request.get_config_ref().get_timeout(), 6000u64); - assert_eq!(request.get_config_ref().get_buffer_size(), 8192usize); + assert_eq!(request.get_config().get_timeout(), 6000u64); + assert_eq!(request.get_config().get_buffer_size(), 8192usize); } #[test] fn test_builder_http_version_toggles() { let mut builder: RequestBuilder = RequestBuilder::new(); let http2: HttpRequest = builder.get("http://a.test/").http2_only().build(); - assert_eq!( - *http2.get_config_ref().get_http_version(), - HttpVersion::Http2 - ); + assert_eq!(*http2.get_config().get_http_version(), HttpVersion::Http2); let mut other: RequestBuilder = RequestBuilder::new(); let http1: HttpRequest = other .get("http://a.test/") .http2_only() .http1_1_only() .build(); - assert_eq!( - *http1.get_config_ref().get_http_version(), - HttpVersion::Http1_1 - ); + assert_eq!(*http1.get_config().get_http_version(), HttpVersion::Http1_1); } #[test] fn test_builder_redirect_toggles() { let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.get("http://a.test/").build(); - assert!(!request.get_config_ref().get_redirect()); + assert!(!request.get_config().get_redirect()); let mut enabled: RequestBuilder = RequestBuilder::new(); let redirected: HttpRequest = enabled.get("http://a.test/").redirect().build(); - assert!(redirected.get_config_ref().get_redirect()); + assert!(redirected.get_config().get_redirect()); let mut disabled: RequestBuilder = RequestBuilder::new(); let plain: HttpRequest = disabled .get("http://a.test/") .redirect() .no_redirect() .build(); - assert!(!plain.get_config_ref().get_redirect()); + assert!(!plain.get_config().get_redirect()); } #[test] @@ -181,20 +175,20 @@ fn test_builder_max_redirect_times() { .get("http://a.test/") .max_redirect_times(5usize) .build(); - assert_eq!(request.get_config_ref().get_max_redirect_times(), 5usize); + assert_eq!(request.get_config().get_max_redirect_times(), 5usize); } #[test] fn test_builder_decode_toggles() { let mut builder: RequestBuilder = RequestBuilder::new(); let request: HttpRequest = builder.get("http://a.test/").build(); - assert!(!request.get_config_ref().get_decode()); + assert!(!request.get_config().get_decode()); let mut enabled: RequestBuilder = RequestBuilder::new(); let decoded: HttpRequest = enabled.get("http://a.test/").decode().build(); - assert!(decoded.get_config_ref().get_decode()); + assert!(decoded.get_config().get_decode()); let mut disabled: RequestBuilder = RequestBuilder::new(); let plain: HttpRequest = disabled.get("http://a.test/").decode().no_decode().build(); - assert!(!plain.get_config_ref().get_decode()); + assert!(!plain.get_config().get_decode()); } #[test] @@ -204,7 +198,7 @@ fn test_builder_proxy_and_no_proxy() { .get("http://a.test/") .proxy(Proxy::socks5("127.0.0.1", 1080u16)) .build(); - let proxy: &Option = request.get_config_ref().get_proxy(); + let proxy: Option = request.get_config().get_proxy(); assert_eq!( proxy.as_ref().map(|item: &Proxy| item.proxy_type), Some(ProxyType::Socks5) @@ -216,7 +210,7 @@ fn test_builder_proxy_and_no_proxy() { .proxy(Proxy::http("h", 1u16)) .no_proxy() .build(); - assert!(direct.get_config_ref().get_proxy().is_none()); + assert!(direct.get_config().get_proxy().is_none()); } #[test] @@ -225,21 +219,21 @@ fn test_builder_build_resets_the_builder() { builder.get("http://a.test/"); let first: HttpRequest = builder.build(); let second: HttpRequest = builder.build(); - assert_eq!(first.get_url_ref(), "http://a.test/"); - assert_eq!(second.get_url_ref(), ""); - assert!(second.get_headers_ref().is_empty()); + assert_eq!(first.get_url(), "http://a.test/"); + assert_eq!(second.get_url(), ""); + assert!(second.get_headers().is_empty()); } #[test] fn test_builder_get_request_mut_exposes_underlying_request() { let mut builder: RequestBuilder = RequestBuilder::new(); - let inner: &mut HttpRequest = builder.get_request_mut(); + let inner: &mut HttpRequest = builder.get_mut_request(); inner.set_url("http://inner.test/"); inner.set_header("X-Token", "v"); let request: HttpRequest = builder.build(); - assert_eq!(request.get_url_ref(), "http://inner.test/"); + assert_eq!(request.get_url(), "http://inner.test/"); assert_eq!( - request.get_headers_ref().get("x-token").map(String::as_str), + request.get_headers().get("x-token").map(String::as_str), Some("v") ); } @@ -251,14 +245,14 @@ fn test_builder_default_and_clone_preserve_state() { let mut cloned: RequestBuilder = base.clone(); let from_clone: HttpRequest = cloned.build(); let from_base: HttpRequest = base.build(); - assert_eq!(from_clone.get_url_ref(), "http://base.test/"); - assert_eq!(from_base.get_url_ref(), "http://base.test/"); + assert_eq!(from_clone.get_url(), "http://base.test/"); + assert_eq!(from_base.get_url(), "http://base.test/"); assert_eq!( - from_clone.get_headers_ref().get("a").map(String::as_str), + from_clone.get_headers().get("a").map(String::as_str), Some("1") ); assert_eq!( - from_base.get_headers_ref().get("a").map(String::as_str), + from_base.get_headers().get("a").map(String::as_str), Some("1") ); } diff --git a/request/tests/request_api/fn.rs b/request/tests/request_api/fn.rs index dfaaedca..13212f81 100644 --- a/request/tests/request_api/fn.rs +++ b/request/tests/request_api/fn.rs @@ -6,9 +6,9 @@ fn test_http_request_get_sets_method_and_url() { assert_eq!(request.get_method(), Method::Get); assert!(request.get_method().is_get()); assert_eq!(request.get_url(), "http://example.com/a"); - assert_eq!(request.get_url_ref(), "http://example.com/a"); + assert_eq!(request.get_url(), "http://example.com/a"); assert!(request.get_headers().is_empty()); - assert!(request.get_body_ref().get_bytes_ref().is_empty()); + assert!(request.get_body().get_bytes().as_slice().is_empty()); } #[test] @@ -16,25 +16,25 @@ fn test_http_request_post_sets_method_and_url() { let request: HttpRequest = HttpRequest::post("https://example.com/b"); assert_eq!(request.get_method(), Method::Post); assert!(request.get_method().is_post()); - assert_eq!(request.get_url_ref(), "https://example.com/b"); + assert_eq!(request.get_url(), "https://example.com/b"); } #[test] fn test_http_request_accepts_owned_url() { let request: HttpRequest = HttpRequest::get(String::from("http://owned.test/")); - assert_eq!(request.get_url_ref(), "http://owned.test/"); + assert_eq!(request.get_url(), "http://owned.test/"); } #[test] fn test_http_request_default_is_empty_request() { let request: HttpRequest = HttpRequest::default(); - assert_eq!(request.get_url_ref(), ""); + assert_eq!(request.get_url(), ""); assert_eq!(request.url, ""); assert!(request.get_method().is_unknown()); - assert!(request.get_headers_ref().is_empty()); - assert!(request.get_body_ref().get_bytes_ref().is_empty()); + assert!(request.get_headers().is_empty()); + assert!(request.get_body().get_bytes().as_slice().is_empty()); assert_eq!( - *request.get_config_ref().get_http_version(), + *request.get_config().get_http_version(), HttpVersion::Http1_1 ); } @@ -59,14 +59,14 @@ fn test_http_request_set_method_overrides_default() { fn test_http_request_set_url_overrides_previous() { let mut request: HttpRequest = HttpRequest::get("http://first.test/"); request.set_url("http://second.test/"); - assert_eq!(request.get_url_ref(), "http://second.test/"); + assert_eq!(request.get_url(), "http://second.test/"); } #[test] fn test_http_request_set_header_normalizes_key_to_lowercase() { let mut request: HttpRequest = HttpRequest::get("http://example.com/"); request.set_header("Content-Type", "application/json"); - let headers: &HashMap = request.get_headers_ref(); + let headers: &HashMap = request.get_headers(); assert_eq!(headers.len(), 1usize); assert_eq!( headers.get("content-type").map(String::as_str), @@ -80,7 +80,7 @@ fn test_http_request_set_header_last_write_wins_across_cases() { let mut request: HttpRequest = HttpRequest::get("http://example.com/"); request.set_header("X-Token", "first"); request.set_header("x-token", "second"); - let headers: &HashMap = request.get_headers_ref(); + let headers: &HashMap = request.get_headers(); assert_eq!(headers.len(), 1usize); assert_eq!(headers.get("x-token").map(String::as_str), Some("second")); assert!(headers.get("X-Token").is_none()); @@ -91,10 +91,10 @@ fn test_http_request_remove_header_is_case_insensitive() { let mut request: HttpRequest = HttpRequest::get("http://example.com/"); request.set_header("X-Token", "value"); request.remove_header("x-token"); - assert!(request.get_headers_ref().is_empty()); + assert!(request.get_headers().is_empty()); request.set_header("X-Token", "value"); request.remove_header("X-TOKEN"); - assert!(request.get_headers_ref().is_empty()); + assert!(request.get_headers().is_empty()); } #[test] @@ -102,7 +102,7 @@ fn test_http_request_remove_missing_header_is_noop() { let mut request: HttpRequest = HttpRequest::get("http://example.com/"); request.set_header("X-Token", "value"); request.remove_header("absent"); - assert_eq!(request.get_headers_ref().len(), 1usize); + assert_eq!(request.get_headers().len(), 1usize); } #[test] @@ -112,7 +112,7 @@ fn test_http_request_clear_headers_drops_all() { request.set_header("B", "2"); assert_eq!(request.get_headers().len(), 2usize); request.clear_headers(); - assert!(request.get_headers_ref().is_empty()); + assert!(request.get_headers().is_empty()); } #[test] @@ -121,7 +121,7 @@ fn test_http_request_get_mut_headers_writes_directly() { let headers: &mut HashMap = request.get_mut_headers(); headers.insert("X-raw".to_string(), "v".to_string()); assert_eq!( - request.get_headers_ref().get("X-raw").map(String::as_str), + request.get_headers().get("X-raw").map(String::as_str), Some("v") ); } @@ -130,76 +130,73 @@ fn test_http_request_get_mut_headers_writes_directly() { fn test_http_request_set_body_replaces_previous() { let mut request: HttpRequest = HttpRequest::post("http://example.com/"); request.set_body(Body::from_bytes("data")); - assert_eq!(request.get_body().get_bytes_ref(), b"data"); - assert_eq!(request.get_body_ref().as_str(), Some("data")); + assert_eq!(request.get_body().get_bytes().as_slice(), b"data"); + assert_eq!(request.get_body().as_str(), Some("data")); request.set_body(Body::empty()); - assert!(request.get_body_ref().get_bytes_ref().is_empty()); + assert!(request.get_body().get_bytes().as_slice().is_empty()); assert_eq!(request.body, Body::empty()); } #[test] fn test_http_request_config_defaults() { let request: HttpRequest = HttpRequest::get("http://example.com/"); - assert_eq!(request.get_config_ref().get_buffer_size(), 0usize); - assert_eq!(request.get_config_ref().get_timeout(), 0u64); - assert_eq!(request.get_config_ref().get_max_redirect_times(), 0usize); + assert_eq!(request.get_config().get_buffer_size(), 0usize); + assert_eq!(request.get_config().get_timeout(), 0u64); + assert_eq!(request.get_config().get_max_redirect_times(), 0usize); assert_eq!( - *request.get_config_ref().get_http_version(), + *request.get_config().get_http_version(), HttpVersion::Http1_1 ); - assert!(!request.get_config_ref().get_redirect()); - assert!(!request.get_config_ref().get_decode()); - assert!(request.get_config_ref().get_proxy().is_none()); + assert!(!request.get_config().get_redirect()); + assert!(!request.get_config().get_decode()); + assert!(request.get_config().get_proxy().is_none()); } #[test] fn test_http_request_config_setters_are_chainable() { let mut request: HttpRequest = HttpRequest::get("http://example.com/"); request - .get_config_mut() + .get_mut_config() .set_buffer_size(4096usize) .set_timeout(1500u64) .set_max_redirect_times(3usize) .set_http_version(HttpVersion::Http2) .set_redirect(true) .set_decode(true); - assert_eq!(request.get_config_ref().get_buffer_size(), 4096usize); - assert_eq!(request.get_config_ref().get_timeout(), 1500u64); - assert_eq!(request.get_config_ref().get_max_redirect_times(), 3usize); - assert_eq!( - *request.get_config_ref().get_http_version(), - HttpVersion::Http2 - ); - assert!(request.get_config_ref().get_redirect()); - assert!(request.get_config_ref().get_decode()); + assert_eq!(request.get_config().get_buffer_size(), 4096usize); + assert_eq!(request.get_config().get_timeout(), 1500u64); + assert_eq!(request.get_config().get_max_redirect_times(), 3usize); + assert_eq!(*request.get_config().get_http_version(), HttpVersion::Http2); + assert!(request.get_config().get_redirect()); + assert!(request.get_config().get_decode()); } #[test] fn test_http_request_config_proxy_setter() { let mut request: HttpRequest = HttpRequest::get("http://example.com/"); request - .get_config_mut() + .get_mut_config() .set_proxy(Some(Proxy::http("h", 1u16))); - let proxy: &Option = request.get_config_ref().get_proxy(); + let proxy: Option = request.get_config().get_proxy(); assert_eq!( proxy.as_ref().map(|item: &Proxy| item.host.clone()), Some("h".to_string()) ); - request.get_config_mut().set_proxy(None); - assert!(request.get_config_ref().get_proxy().is_none()); + request.get_mut_config().set_proxy(None); + assert!(request.get_config().get_proxy().is_none()); } #[test] fn test_http_request_set_config_replaces_whole_config() { let mut source: HttpRequest = HttpRequest::get("http://source.test/"); - source.get_config_mut().set_timeout(1234u64); - source.get_config_mut().set_buffer_size(64usize); + source.get_mut_config().set_timeout(1234u64); + source.get_mut_config().set_buffer_size(64usize); let mut target: HttpRequest = HttpRequest::post("http://target.test/"); - target.get_config_mut().set_timeout(9999u64); - target.set_config(source.get_config()); + target.get_mut_config().set_timeout(9999u64); + target.set_config(source.get_config().clone()); assert_eq!(target.get_config(), source.get_config()); - assert_eq!(target.get_config_ref().get_timeout(), 1234u64); - assert_eq!(target.get_config_ref().get_buffer_size(), 64usize); + assert_eq!(target.get_config().get_timeout(), 1234u64); + assert_eq!(target.get_config().get_buffer_size(), 64usize); assert_eq!(target.config, source.config); } @@ -207,7 +204,7 @@ fn test_http_request_set_config_replaces_whole_config() { fn test_http_request_get_config_returns_owned_copy() { let request: HttpRequest = HttpRequest::get("http://example.com/"); assert_eq!(request.get_config(), request.get_config()); - assert_eq!(request.config, request.get_config()); + assert_eq!(request.config, request.get_config().clone()); } #[test] @@ -216,10 +213,10 @@ fn test_http_request_clone_copies_headers_and_body() { request.set_header("X-Token", "v"); request.set_body(Body::from_bytes("payload")); let cloned: HttpRequest = request.clone(); - assert_eq!(cloned.get_url_ref(), request.get_url_ref()); + assert_eq!(cloned.get_url(), request.get_url()); assert_eq!(cloned.get_method(), request.get_method()); - assert_eq!(cloned.get_headers_ref(), request.get_headers_ref()); - assert_eq!(cloned.get_body_ref(), request.get_body_ref()); + assert_eq!(cloned.get_headers(), request.get_headers()); + assert_eq!(cloned.get_body(), request.get_body()); } #[test] diff --git a/type/src/body/const.rs b/type/src/body/const.rs new file mode 100644 index 00000000..92e787ee --- /dev/null +++ b/type/src/body/const.rs @@ -0,0 +1,62 @@ +/// The `Content-Type` value that declares a `multipart/form-data` body. +pub(crate) const MULTIPART_FORM_DATA: &str = "multipart/form-data"; + +/// The `boundary` parameter name carried by a `multipart/form-data` content type. +pub(crate) const MULTIPART_BOUNDARY_PARAMETER: &str = "boundary"; + +/// The `name` parameter name carried by a part `Content-Disposition` header. +pub(crate) const MULTIPART_NAME_PARAMETER: &str = "name"; + +/// The `Content-Disposition` header name carried by every `multipart/form-data` part. +pub(crate) const MULTIPART_CONTENT_DISPOSITION: &str = "content-disposition"; + +/// The dash pair that opens a boundary line and closes the closing boundary. +pub(crate) const MULTIPART_DELIMITER_DASH: &str = "--"; + +/// The quote character wrapping a `Content-Disposition` or `boundary` parameter value. +pub(crate) const MULTIPART_PARAMETER_QUOTE: &str = "\""; + +/// The maximum number of parts accepted from a single `multipart/form-data` body. +pub(crate) const MULTIPART_MAX_PARTS: usize = 1024; + +/// The bare line feed used by lenient parsers that accept `\n` line breaks. +pub(crate) const MULTIPART_LINE_FEED: &[u8] = b"\n"; + +/// The radix of a hexadecimal digit pair in a percent escape. +pub(crate) const HEX_RADIX: u8 = 16; + +/// The offset turning a hexadecimal letter digit into its numeric value. +pub(crate) const HEX_LOWER_OFFSET: u8 = 10; + +/// The blank-line separator between a part header block and its payload. +pub(crate) const MULTIPART_HEADER_SEPARATOR: &[u8] = b"\n\n"; + +/// The byte offset consumed by [`MULTIPART_HEADER_SEPARATOR`]. +pub(crate) const MULTIPART_HEADER_SEPARATOR_LEN: usize = 2; + +/// The byte a `+` in a URL-encoded form stands in for. +pub(crate) const URL_ENCODED_SPACE: u8 = b' '; + +/// The byte that opens a percent escape in a URL-encoded form. +pub(crate) const URL_ENCODED_PERCENT: u8 = b'%'; + +/// The byte that stands in for a space in a URL-encoded form. +pub(crate) const URL_ENCODED_PLUS: u8 = b'+'; + +/// The first byte of the decimal digit range in a percent escape. +pub(crate) const HEX_DIGIT_ZERO: u8 = b'0'; + +/// The last byte of the decimal digit range in a percent escape. +pub(crate) const HEX_DIGIT_NINE: u8 = b'9'; + +/// The first byte of the lowercase hexadecimal range in a percent escape. +pub(crate) const HEX_DIGIT_LOWER_A: u8 = b'a'; + +/// The last byte of the lowercase hexadecimal range in a percent escape. +pub(crate) const HEX_DIGIT_LOWER_F: u8 = b'f'; + +/// The first byte of the uppercase hexadecimal range in a percent escape. +pub(crate) const HEX_DIGIT_UPPER_A: u8 = b'A'; + +/// The last byte of the uppercase hexadecimal range in a percent escape. +pub(crate) const HEX_DIGIT_UPPER_F: u8 = b'F'; diff --git a/type/src/body/impl.rs b/type/src/body/impl.rs new file mode 100644 index 00000000..161bbb8e --- /dev/null +++ b/type/src/body/impl.rs @@ -0,0 +1,583 @@ +use super::*; + +/// Implementation for `MultipartFormData`. +impl MultipartFormData { + /// Reads the `boundary` parameter out of a `multipart/form-data` content type. + /// + /// The parameter is matched case-insensitively and accepts both a quoted and + /// a bare value, so the clients that emit `boundary="abc"` and the clients + /// that emit `boundary=abc` are both accepted. A parameter that carries no + /// `=` is skipped rather than aborting the scan. + /// + /// # Arguments + /// + /// - `C` - The content type header value, which requires the `AsRef` bound. + /// + /// # Returns + /// + /// - `Option` - The declared boundary, or `None` when absent. + pub fn get_boundary(content_type: C) -> Option + where + C: AsRef, + { + for parameter in content_type.as_ref().split(SEMICOLON).skip(1) { + let Some((key, value)) = parameter.trim().split_once(EQUAL) else { + continue; + }; + if !key + .trim() + .eq_ignore_ascii_case(MULTIPART_BOUNDARY_PARAMETER) + { + continue; + } + let value: &str = Self::unquote(value.trim()); + if !value.is_empty() { + return Some(value.to_string()); + } + } + None + } + + /// Checks whether a content type declares a `multipart/form-data` body. + /// + /// # Arguments + /// + /// - `C` - The content type header value, which requires the `AsRef` bound. + /// + /// # Returns + /// + /// - `bool` - Whether the content type is `multipart/form-data`. + pub fn is_form_data(content_type: C) -> bool + where + C: AsRef, + { + content_type + .as_ref() + .split(SEMICOLON) + .next() + .unwrap_or(EMPTY_STR) + .trim() + .eq_ignore_ascii_case(MULTIPART_FORM_DATA) + } + + /// Strips one pair of surrounding quotes from a header parameter value. + /// + /// # Arguments + /// + /// - `&str` - The raw parameter value. + /// + /// # Returns + /// + /// - `&str` - The value without its surrounding quotes. + fn unquote(value: &str) -> &str { + value + .strip_prefix(MULTIPART_PARAMETER_QUOTE) + .and_then(|value: &str| value.strip_suffix(MULTIPART_PARAMETER_QUOTE)) + .unwrap_or(value) + } + + /// Finds the first occurrence of a needle inside a haystack. + /// + /// # Arguments + /// + /// - `&[u8]` - The bytes to search in. + /// - `&[u8]` - The bytes to search for. + /// + /// # Returns + /// + /// - `Option` - The offset of the first occurrence, or `None` when absent. + fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|window: &[u8]| window == needle) + } + + /// Builds the byte delimiter that marks a boundary line in the body. + /// + /// # Arguments + /// + /// - `&str` - The declared boundary, without its leading dash pair. + /// + /// # Returns + /// + /// - `Vec` - The delimiter bytes, dash pair included. + fn build_delimiter(boundary: &str) -> Vec { + let mut delimiter: Vec = Vec::with_capacity(boundary.len() + 4); + delimiter.extend_from_slice(MULTIPART_DELIMITER_DASH.as_bytes()); + delimiter.extend_from_slice(boundary.as_bytes()); + delimiter + } + + /// Checks whether the bytes after a delimiter are a closing dash pair. + /// + /// The closing boundary writes a second dash pair right after the boundary + /// token, optionally followed by the line break that ends the body. + /// + /// # Arguments + /// + /// - `&[u8]` - The bytes that follow the delimiter. + /// + /// # Returns + /// + /// - `bool` - Whether the delimiter is closed by a dash pair. + fn is_closing_delimiter(remainder: &[u8]) -> bool { + let Some(after_dash) = remainder.strip_prefix(MULTIPART_DELIMITER_DASH.as_bytes()) else { + return false; + }; + after_dash.is_empty() + || after_dash.starts_with(HTTP_BR.as_bytes()) + || after_dash.starts_with(MULTIPART_LINE_FEED) + } + + /// Checks whether the bytes after a delimiter close a boundary line. + /// + /// A boundary line ends at a line break or at the end of the body, so the + /// dash pair of the closing boundary is matched here rather than being part + /// of the delimiter itself. + /// + /// # Arguments + /// + /// - `&[u8]` - The bytes that follow the delimiter. + /// + /// # Returns + /// + /// - `bool` - Whether the delimiter is followed by a boundary line ending. + fn is_boundary_tail(remainder: &[u8]) -> bool { + remainder.starts_with(HTTP_BR.as_bytes()) + || remainder.starts_with(MULTIPART_LINE_FEED) + || Self::is_closing_delimiter(remainder) + } + + /// Finds every byte offset at which the boundary delimiter starts. + /// + /// # Arguments + /// + /// - `&[u8]` - The raw request body. + /// - `&[u8]` - The boundary delimiter, dash pair included. + /// + /// # Returns + /// + /// - `Vec` - The ascending offsets of every delimiter occurrence. + fn find_delimiter_positions(body: &[u8], delimiter: &[u8]) -> Vec { + let mut positions: Vec = Vec::new(); + let mut offset: usize = 0; + while offset < body.len() { + let Some(found) = Self::find_subslice(&body[offset..], delimiter) else { + break; + }; + let position: usize = offset + found; + let after: usize = position + delimiter.len(); + if after == body.len() || Self::is_boundary_tail(&body[after..]) { + positions.push(position); + } + offset = position + 1; + } + positions + } + + /// Removes the line breaks that belong to a boundary line from a segment. + /// + /// The break that follows the opening delimiter is stripped from the front, + /// and the break that precedes the closing delimiter is stripped from the + /// back: per the multipart grammar both belong to the boundary line, not to + /// the part content. + /// + /// # Arguments + /// + /// - `&[u8]` - The raw segment between two boundary lines. + /// + /// # Returns + /// + /// - `&[u8]` - The segment without its surrounding boundary line breaks. + fn trim_boundary_breaks(segment: &[u8]) -> &[u8] { + let segment: &[u8] = segment.strip_prefix(HTTP_BR.as_bytes()).unwrap_or(segment); + if let Some(trimmed) = segment.strip_suffix(HTTP_BR.as_bytes()) { + return trimmed; + } + segment.strip_suffix(MULTIPART_LINE_FEED).unwrap_or(segment) + } + + /// Splits a body into the raw segments carried between boundary lines. + /// + /// Both `CRLF` and bare `LF` line breaks are accepted, and the breaks that + /// belong to a boundary line are not part of a segment. The final boundary + /// must be the closing one: a body whose last boundary opens a part that is + /// never closed is truncated, and reporting it as `None` keeps a truncated + /// upload from passing as a complete one. + /// + /// # Arguments + /// + /// - `&'a [u8]` - The raw request body. + /// - `&str` - The declared boundary, without its leading dash pair. + /// + /// # Returns + /// + /// - `Option>` - The body segments, or `None` when malformed. + fn split_segments<'a>(body: &'a [u8], boundary: &str) -> Option> { + let delimiter: Vec = Self::build_delimiter(boundary); + let positions: Vec = Self::find_delimiter_positions(body, &delimiter); + let last_position: usize = positions.last().copied()?; + let last_end: usize = last_position + delimiter.len(); + if !Self::is_closing_delimiter(&body[last_end..]) { + return None; + } + let mut segments: MultipartFormDataSegments = Vec::with_capacity(positions.len()); + for index in 0..positions.len().saturating_sub(1) { + let start: usize = positions[index] + delimiter.len(); + let end: usize = positions[index + 1]; + segments.push(Self::trim_boundary_breaks(&body[start..end])); + } + Some(segments) + } + + /// Splits a part into its header block and its content. + /// + /// # Arguments + /// + /// - `&[u8]` - The raw segment of a single part. + /// + /// # Returns + /// + /// - `(&[u8], &[u8])` - The header block and the content of the part. + fn split_part(segment: &[u8]) -> (&[u8], &[u8]) { + let fallback: &[u8] = &segment[segment.len()..]; + if let Some(position) = Self::find_subslice(segment, HTTP_DOUBLE_BR.as_bytes()) { + let data_start: usize = position + HTTP_DOUBLE_BR.len(); + return (&segment[..position], &segment[data_start..]); + } + if let Some(position) = Self::find_subslice(segment, MULTIPART_HEADER_SEPARATOR) { + let data_start: usize = position + MULTIPART_HEADER_SEPARATOR_LEN; + return (&segment[..position], &segment[data_start..]); + } + (segment, fallback) + } + + /// Decodes the header block of a single part. + /// + /// # Arguments + /// + /// - `&[u8]` - The header block of the part, terminated by a blank line. + /// + /// # Returns + /// + /// - `Option` - The decoded headers, or `None` when not valid text. + fn parse_part_headers(header_block: &[u8]) -> Option { + let header_text: &str = from_utf8(header_block).ok()?; + let mut headers: BodyPartHeaders = hash_map_xx_hash3_64(); + for line in header_text.split(HTTP_BR) { + let line: &str = line.trim_end_matches('\n').trim(); + if line.is_empty() { + continue; + } + let (key, value) = line.split_once(COLON)?; + let key: String = key.trim().to_ascii_lowercase(); + if key.is_empty() { + continue; + } + headers.insert(key, value.trim().to_string()); + } + Some(headers) + } + + /// Reads the `name` and `filename` parameters of a part content disposition. + /// + /// # Arguments + /// + /// - `&str` - The part `Content-Disposition` header value. + /// + /// # Returns + /// + /// - `(BodyPartName, BodyPartFileName)` - The declared field name and file name. + fn parse_part_disposition(disposition: &str) -> (BodyPartName, BodyPartFileName) { + let mut name: BodyPartName = String::new(); + let mut file_name: BodyPartFileName = String::new(); + for parameter in disposition.split(SEMICOLON).skip(1) { + let Some((key, value)) = parameter.trim().split_once(EQUAL) else { + continue; + }; + let value: &str = Self::unquote(value.trim()); + match key.trim().to_ascii_lowercase().as_str() { + MULTIPART_NAME_PARAMETER => name = value.to_string(), + FILENAME => file_name = value.to_string(), + _ => {} + } + } + (name, file_name) + } + + /// Decodes every part of a `multipart/form-data` body. + /// + /// A body that carries no declared boundary, a body whose final boundary is + /// not the closing one, and a part that declares no `name` all yield `None`. + /// + /// # Arguments + /// + /// - `B` - The raw request body, which requires the `AsRef<[u8]>` bound. + /// - `&str` - The declared boundary, without its leading dash pair. + /// + /// # Returns + /// + /// - `Option` - The decoded parts, or `None` when malformed. + pub fn parse(body: B, boundary: &str) -> Option + where + B: AsRef<[u8]>, + { + let segments: MultipartFormDataSegments = Self::split_segments(body.as_ref(), boundary)?; + let mut parts: MultipartFormDataParts = Vec::with_capacity(segments.len()); + for segment in segments { + if segment.is_empty() { + continue; + } + let (header_block, data) = Self::split_part(segment); + let headers: BodyPartHeaders = Self::parse_part_headers(header_block)?; + let disposition: String = headers + .get(MULTIPART_CONTENT_DISPOSITION) + .cloned() + .unwrap_or_default(); + let (name, file_name) = Self::parse_part_disposition(&disposition); + if name.is_empty() || parts.len() >= MULTIPART_MAX_PARTS { + return None; + } + let content_type: BodyPartContentType = + headers.get(CONTENT_TYPE).cloned().unwrap_or_default(); + parts.push(BodyPart::new( + name, + file_name, + content_type, + headers, + data.to_vec(), + )); + } + Some(parts) + } + + /// Tries to get a part by its form field name. + /// + /// # Arguments + /// + /// - `&[BodyPart]` - The decoded parts of the body. + /// - `K` - The form field name, which requires the `AsRef` bound. + /// + /// # Returns + /// + /// - `Option<&BodyPart>` - The first part carrying that name, or `None`. + pub fn try_get_part(parts: &[BodyPart], name: K) -> Option<&BodyPart> + where + K: AsRef, + { + let name: &str = name.as_ref(); + parts + .iter() + .find(|part: &&BodyPart| part.get_name() == name) + } + + /// Tries to get a plain text field by its form field name. + /// + /// A part that uploads a file is skipped, so a file field never silently + /// decodes as text. + /// + /// # Arguments + /// + /// - `&[BodyPart]` - The decoded parts of the body. + /// - `K` - The form field name, which requires the `AsRef` bound. + /// + /// # Returns + /// + /// - `Option` - The field value, or `None` when absent or a file. + pub fn try_get_part_text(parts: &[BodyPart], name: K) -> Option + where + K: AsRef, + { + let part: &BodyPart = Self::try_get_part(parts, name)?; + if !part.get_file_name().is_empty() { + return None; + } + Some(String::from_utf8_lossy(part.get_data()).into_owned()) + } + + /// Decodes an `application/x-www-form-urlencoded` body into its fields. + /// + /// # Arguments + /// + /// - `B` - The raw request body, which requires the `AsRef<[u8]>` bound. + /// + /// # Returns + /// + /// - `BodyForm` - The decoded form fields. + pub fn parse_url_encoded(body: B) -> BodyForm + where + B: AsRef<[u8]>, + { + let decoded: String = String::from_utf8_lossy(body.as_ref()).into_owned(); + let mut form: BodyForm = hash_map_xx_hash3_64(); + for pair in decoded.split(AND) { + if pair.is_empty() { + continue; + } + let (key, value) = pair.split_once(EQUAL).unwrap_or((pair, EMPTY_STR)); + let key: BodyFormKey = Self::decode_form_component(key); + if key.is_empty() { + continue; + } + form.insert(key, Self::decode_form_component(value)); + } + form + } + + /// Decodes one percent-encoded form component. + /// + /// `+` is decoded as a space, `%XX` as the matching byte, and an invalid + /// escape is kept verbatim instead of dropping the component. + /// + /// # Arguments + /// + /// - `&str` - The raw component of the form body. + /// + /// # Returns + /// + /// - `String` - The decoded component. + fn decode_form_component(component: &str) -> String { + let bytes: Vec = component.bytes().collect(); + let mut decoded: Vec = Vec::with_capacity(bytes.len()); + let mut index: usize = 0; + while index < bytes.len() { + let byte: u8 = bytes[index]; + match byte { + URL_ENCODED_PLUS => { + decoded.push(URL_ENCODED_SPACE); + index += 1; + } + byte if byte == URL_ENCODED_PERCENT && index + 2 < bytes.len() => { + match Self::parse_hex_pair(&bytes[index + 1], &bytes[index + 2]) { + Some(value) => { + decoded.push(value); + index += 3; + } + None => { + decoded.push(byte); + index += 1; + } + } + } + _ => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8_lossy(&decoded).into_owned() + } + + /// Reads the two hexadecimal digits of a percent escape. + /// + /// # Arguments + /// + /// - `&u8` - The high digit of the escape. + /// - `&u8` - The low digit of the escape. + /// + /// # Returns + /// + /// - `Option` - The decoded byte, or `None` when a digit is not hexadecimal. + fn parse_hex_pair(high: &u8, low: &u8) -> Option { + let high_digit: u8 = Self::hex_digit(high)?; + let low_digit: u8 = Self::hex_digit(low)?; + Some(high_digit * HEX_RADIX + low_digit) + } + + /// Converts one hexadecimal digit into its numeric value. + /// + /// # Arguments + /// + /// - `&u8` - The ASCII byte of the digit. + /// + /// # Returns + /// + /// - `Option` - The numeric value, or `None` when the byte is not hexadecimal. + fn hex_digit(byte: &u8) -> Option { + match byte { + HEX_DIGIT_ZERO..=HEX_DIGIT_NINE => Some(byte - HEX_DIGIT_ZERO), + HEX_DIGIT_LOWER_A..=HEX_DIGIT_LOWER_F => { + Some(byte - HEX_DIGIT_LOWER_A + HEX_LOWER_OFFSET) + } + HEX_DIGIT_UPPER_A..=HEX_DIGIT_UPPER_F => { + Some(byte - HEX_DIGIT_UPPER_A + HEX_LOWER_OFFSET) + } + _ => None, + } + } +} + +impl BodyPart { + /// Tries to get the form field name of this part. + /// + /// # Returns + /// + /// - `Option<&BodyPartName>` - The declared field name, or `None` when + /// the part declares none. + pub fn try_get_name(&self) -> Option<&BodyPartName> { + (!self.get_name().is_empty()).then_some(self.get_name()) + } + + /// Tries to get the uploaded file name of this part. + /// + /// # Returns + /// + /// - `Option<&BodyPartFileName>` - The declared file name, or `None` + /// when this part is a plain field rather than a file upload. + pub fn try_get_file_name(&self) -> Option<&BodyPartFileName> { + (!self.get_file_name().is_empty()).then_some(self.get_file_name()) + } + + /// Tries to get the media type declared by this part. + /// + /// # Returns + /// + /// - `Option<&BodyPartContentType>` - The declared media type, or + /// `None` when the part declares none. + pub fn try_get_content_type(&self) -> Option<&BodyPartContentType> { + (!self.get_content_type().is_empty()).then_some(self.get_content_type()) + } + + /// Tries to get the value of one of this part's headers. + /// + /// # Arguments + /// + /// - `K` - The header name, which must implement `AsRef`. + /// + /// # Returns + /// + /// - `Option<&BodyPartHeaderValue>` - The header value, or `None` + /// when this part carries no such header. + pub fn try_get_header(&self, key: K) -> Option<&BodyPartHeaderValue> + where + K: AsRef, + { + self.get_headers().get(key.as_ref()) + } + + /// Tries to get this part's raw content. + /// + /// # Returns + /// + /// - `Option<&[u8]>` - The raw bytes, or `None` when the part is empty. + pub fn try_get_data(&self) -> Option<&[u8]> { + (!self.get_data().is_empty()).then(|| self.get_data().as_slice()) + } + + /// Tries to get this part's raw content as UTF-8 text. + /// + /// Invalid UTF-8 is replaced rather than rejected, so a binary upload + /// read as text yields its readable bytes instead of `None`. + /// + /// # Returns + /// + /// - `Option` - The decoded text, or `None` when the + /// part is empty. + pub fn try_get_text(&self) -> Option { + self.try_get_data() + .map(|data: &[u8]| String::from_utf8_lossy(data).into_owned()) + } +} diff --git a/type/src/body/mod.rs b/type/src/body/mod.rs new file mode 100644 index 00000000..c405b891 --- /dev/null +++ b/type/src/body/mod.rs @@ -0,0 +1,10 @@ +mod r#const; +mod r#impl; +mod r#struct; +mod r#type; + +pub use {r#struct::*, r#type::*}; + +pub(crate) use r#const::*; + +use super::*; diff --git a/type/src/body/struct.rs b/type/src/body/struct.rs new file mode 100644 index 00000000..d3cf3c91 --- /dev/null +++ b/type/src/body/struct.rs @@ -0,0 +1,48 @@ +use super::*; + +/// A single part of a `multipart/form-data` body. +/// +/// A part carries the form field name declared by its `Content-Disposition` +/// header, the file name of an uploaded file when the part is a file, the +/// headers that belong to the part alone, and its raw content. +/// +/// Storage is `pub(super)` (§17.14): a caller never touches the fields +/// directly. The public read surface is the `try_get_*` family below, +/// which follows the same `Option`-returning convention as +/// [`crate::Request`] and [`crate::Response`] and therefore never fails +/// loudly on a part that lacks an optional attribute. +#[derive(Clone, Data, Debug, Default, Deserialize, DisplayDebug, Eq, New, PartialEq, Serialize)] +pub struct BodyPart { + /// Form field name declared by the part `Content-Disposition` header. + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] + pub(super) name: BodyPartName, + /// File name of an uploaded file, empty when the part is a plain field. + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] + pub(super) file_name: BodyPartFileName, + /// Media type declared by the part `Content-Type` header. + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] + pub(super) content_type: BodyPartContentType, + /// Headers carried by this part alone. + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] + pub(super) headers: BodyPartHeaders, + /// Raw content of this part. + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] + pub(super) data: BodyPartData, +} + +/// Parser for `multipart/form-data` bodies. +/// +/// Decodes a body into its parts using the boundary declared by the request +/// `Content-Type` header, without depending on any external multipart crate. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct MultipartFormData; diff --git a/type/src/body/type.rs b/type/src/body/type.rs new file mode 100644 index 00000000..c176d209 --- /dev/null +++ b/type/src/body/type.rs @@ -0,0 +1,46 @@ +use super::*; + +/// An alias for `String`, representing the boundary declared by a `multipart/form-data` content type. +pub type MultipartFormDataBoundary = String; + +/// An alias for `Vec`, representing every part of a parsed `multipart/form-data` body. +pub type MultipartFormDataParts = Vec; + +/// An alias for `Vec<&[u8]>`, representing the raw segments carried between two boundary lines. +pub type MultipartFormDataSegments<'a> = Vec<&'a [u8]>; + +/// An alias for `String`, representing the form field name declared by a body part. +pub type BodyPartName = String; + +/// An alias for `String`, representing the file name declared by an uploaded body part. +pub type BodyPartFileName = String; + +/// An alias for `String`, representing the media type declared by a body part. +pub type BodyPartContentType = String; + +/// An alias for `String`, representing the key of a body part header. +pub type BodyPartHeaderKey = String; + +/// An alias for `String`, representing the value of a body part header. +pub type BodyPartHeaderValue = String; + +/// An alias for `HashMapXxHash3_64`, representing the headers a single part carries. +pub type BodyPartHeaders = HashMapXxHash3_64; + +/// An alias for `Vec`, representing the raw content of a body part. +pub type BodyPartData = Vec; + +/// An alias for `HashMapXxHash3_64`, representing a decoded `application/x-www-form-urlencoded` body. +pub type BodyForm = HashMapXxHash3_64; + +/// An alias for `String`, representing the key of a form field. +pub type BodyFormKey = String; + +/// An alias for `String`, representing the value of a form field. +pub type BodyFormValue = String; + +/// An alias for `String`, representing the request media type without its parameters. +pub type RequestContentType = String; + +/// An alias for `String`, representing one parameter of a request `Content-Type` header. +pub type RequestContentTypeParameter = String; diff --git a/type/src/http_url/struct.rs b/type/src/http_url/struct.rs index 7b5475ee..53650e2b 100644 --- a/type/src/http_url/struct.rs +++ b/type/src/http_url/struct.rs @@ -4,18 +4,39 @@ use super::*; /// /// This struct holds various parts of a URL, including the protocol, host, port, /// path, query, and fragment, allowing for structured access to URL information. -#[derive(Clone, Debug, Default, Eq, PartialEq)] +/// +/// The fields stay `pub`: `type/tests/http_url` reads them directly, so this is +/// published API and §17.14 does not recall it (semver). Mutators stay +/// `pub(super)` — nothing outside this crate builds the value. +#[derive(Clone, Data, Debug, Default, Eq, PartialEq)] pub struct HttpUrlComponents { /// The URL scheme, such as "http" or "https". + #[get_mut(pub(super))] + #[set(pub(super))] pub protocol: String, /// The host part of the URL. + #[get(type(clone))] + #[get_mut(pub(super))] + #[set(pub(super))] pub host: Option, /// The port number in the URL, if specified. + #[get(type(clone))] + #[get_mut(pub(super))] + #[set(pub(super))] pub port: Option, /// The path in the URL. + #[get(type(clone))] + #[get_mut(pub(super))] + #[set(pub(super))] pub path: Option, /// The query string in the URL. + #[get(type(clone))] + #[get_mut(pub(super))] + #[set(pub(super))] pub query: Option, /// The fragment identifier. + #[get(type(clone))] + #[get_mut(pub(super))] + #[set(pub(super))] pub fragment: Option, } diff --git a/type/src/lib.rs b/type/src/lib.rs index dc517d41..4d2f5339 100644 --- a/type/src/lib.rs +++ b/type/src/lib.rs @@ -6,6 +6,7 @@ mod any; mod arc_mutex; mod arc_rwlock; mod attribute; +mod body; mod box_leak; mod box_rwlock; mod content_type; @@ -30,10 +31,11 @@ mod upgrade_type; mod websocket_frame; pub use { - any::*, arc_mutex::*, arc_rwlock::*, attribute::*, box_leak::*, box_rwlock::*, content_type::*, - cookie::*, file_extension::*, hash_map_xx_hash3_64::*, hash_set_xx_hash3_64::*, http_status::*, - http_url::*, http_version::*, lifetime::*, methods::*, panic::*, protocol::*, rc_rwlock::*, - request::*, response::*, status::*, stream::*, task::*, upgrade_type::*, websocket_frame::*, + any::*, arc_mutex::*, arc_rwlock::*, attribute::*, body::*, box_leak::*, box_rwlock::*, + content_type::*, cookie::*, file_extension::*, hash_map_xx_hash3_64::*, + hash_set_xx_hash3_64::*, http_status::*, http_url::*, http_version::*, lifetime::*, methods::*, + panic::*, protocol::*, rc_rwlock::*, request::*, response::*, status::*, stream::*, task::*, + upgrade_type::*, websocket_frame::*, }; pub use {http_compress::*, http_constant::*, serde_json, tokio}; diff --git a/type/src/request/impl.rs b/type/src/request/impl.rs index fc7185fa..018fe970 100644 --- a/type/src/request/impl.rs +++ b/type/src/request/impl.rs @@ -409,7 +409,8 @@ impl Request { /// Parses a query string as_ref key-value pairs into the given map. /// - /// Expects format "key1=value1&key2=value2". Empty values are allowed. + /// Expects format "key1=value1&key2=value2". Empty values are allowed, and a + /// key that repeats keeps every value instead of letting the last one win. /// The target map is expected to be empty; entries are inserted without /// reallocating the map when it already has sufficient capacity. /// @@ -425,10 +426,13 @@ impl Request { for pair in query.split(AND) { if let Some((key, value)) = pair.split_once(EQUAL) { if !key.is_empty() { - querys.insert(key.to_string(), value.to_string()); + querys + .entry(key.to_string()) + .or_default() + .push_back(value.to_string()); } } else if !pair.is_empty() { - querys.insert(pair.to_string(), String::new()); + querys.entry(pair.to_string()).or_default(); } } } @@ -627,9 +631,10 @@ impl Request { Ok(()) } - /// Tries to get a query parameter value by key. + /// Tries to get a query parameter by key. /// - /// The key type must implement AsRef conversion. + /// The key type must implement AsRef conversion. A key that repeats in + /// the query carries every one of its values. /// /// # Arguments /// @@ -637,18 +642,19 @@ impl Request { /// /// # Returns /// - /// - `Option` - The parameter value if exists. + /// - `Option` - Every value of the parameter if it exists. #[inline(always)] pub fn try_get_query(&self, key: K) -> Option where K: AsRef, { - self.querys.get(key.as_ref()).cloned() + self.get_querys().get(key.as_ref()).cloned() } - /// Gets a query parameter value by key. + /// Tries to get the first query parameter value by key. /// - /// The key type must implement AsRef conversion. + /// Use this when the parameter is expected to appear once; use + /// `try_get_query` when every value matters. /// /// # Arguments /// @@ -656,7 +662,81 @@ impl Request { /// /// # Returns /// - /// - `RequestQuerysValue` - The parameter value if exists. + /// - `Option` - The first parameter value if it exists. + #[inline(always)] + pub fn try_get_query_front(&self, key: K) -> Option + where + K: AsRef, + { + self.get_querys() + .get(key.as_ref()) + .and_then(|values: &VecDeque| values.front().cloned()) + } + + /// Tries to get the last query parameter value by key. + /// + /// # Arguments + /// + /// - `K` - The query parameter key, which implements `AsRef`. + /// + /// # Returns + /// + /// - `Option` - The last parameter value if it exists. + #[inline(always)] + pub fn try_get_query_back(&self, key: K) -> Option + where + K: AsRef, + { + self.get_querys() + .get(key.as_ref()) + .and_then(|values: &VecDeque| values.back().cloned()) + } + + /// Tries to get the number of values of a query parameter. + /// + /// # Arguments + /// + /// - `K` - The query parameter key, which implements `AsRef`. + /// + /// # Returns + /// + /// - `Option` - The number of values if the parameter exists. + #[inline(always)] + pub fn try_get_query_size(&self, key: K) -> Option + where + K: AsRef, + { + self.get_querys() + .get(key.as_ref()) + .map(|values: &VecDeque| values.len()) + } + + /// Checks whether a query parameter exists. + /// + /// # Arguments + /// + /// - `K` - The query parameter key, which implements `AsRef`. + /// + /// # Returns + /// + /// - `bool` - Whether the parameter exists. + #[inline(always)] + pub fn has_query(&self, key: K) -> bool + where + K: AsRef, + { + self.get_querys().contains_key(key.as_ref()) + } + + /// Gets a query parameter by key. + /// + /// # Arguments + /// + /// - `K` - The query parameter key, which implements `AsRef`. + /// + /// # Returns + /// + /// - `RequestQuerysValue` - Every value of the parameter. /// /// # Panics /// @@ -669,8 +749,44 @@ impl Request { self.try_get_query(key).unwrap() } + /// Gets the first query parameter value by key. + /// + /// # Arguments + /// + /// - `K` - The query parameter key, which implements `AsRef`. + /// + /// # Returns + /// + /// - `RequestQuerysValueItem` - The first parameter value. + /// + /// # Panics + /// + /// This function will panic if the query parameter key is not found. + #[inline(always)] + pub fn get_query_front(&self, key: K) -> RequestQuerysValueItem + where + K: AsRef, + { + self.try_get_query_front(key).unwrap() + } + + /// Retrieves the number of query parameters. + /// + /// # Returns + /// + /// - `usize` - The count of distinct query parameter keys. + #[inline(always)] + pub fn get_querys_size(&self) -> usize { + self.get_querys().len() + } + /// Tries to retrieve the value of a request header by its key. /// + /// The key is matched case-insensitively, because the parser stores every + /// header name lowercased and an HTTP header name is case-insensitive by + /// specification. Looking a header up with `Content-Type` and with + /// `content-type` therefore find the same value. + /// /// # Arguments /// /// - `K` - The header's key, which must implement `AsRef`. @@ -683,7 +799,9 @@ impl Request { where K: AsRef, { - self.headers.get(key.as_ref()).cloned() + self.get_headers() + .get(&key.as_ref().to_ascii_lowercase()) + .cloned() } /// Retrieves the value of a request header by its key. @@ -721,8 +839,8 @@ impl Request { where K: AsRef, { - self.headers - .get(key.as_ref()) + self.get_headers() + .get(&key.as_ref().to_ascii_lowercase()) .and_then(|header_values: &VecDeque| header_values.front().cloned()) } @@ -761,8 +879,8 @@ impl Request { where K: AsRef, { - self.headers - .get(key.as_ref()) + self.get_headers() + .get(&key.as_ref().to_ascii_lowercase()) .and_then(|header_values: &VecDeque| header_values.back().cloned()) } @@ -801,8 +919,8 @@ impl Request { where K: AsRef, { - self.headers - .get(key.as_ref()) + self.get_headers() + .get(&key.as_ref().to_ascii_lowercase()) .map(|header_values: &VecDeque| header_values.len()) } @@ -834,7 +952,7 @@ impl Request { /// - `usize` - The total count of all header values. #[inline(always)] pub fn get_headers_values_size(&self) -> usize { - self.headers + self.get_headers() .values() .map(|header_values: &VecDeque| header_values.len()) .sum() @@ -847,7 +965,7 @@ impl Request { /// - `usize` - The count of unique header keys. #[inline(always)] pub fn get_headers_size(&self) -> usize { - self.headers.len() + self.get_headers().len() } /// Checks if a specific header exists. @@ -864,7 +982,8 @@ impl Request { where K: AsRef, { - self.get_headers().contains_key(key.as_ref()) + self.get_headers() + .contains_key(&key.as_ref().to_ascii_lowercase()) } /// Checks if a header contains a specific value. @@ -883,7 +1002,7 @@ impl Request { K: AsRef, V: AsRef, { - if let Some(values) = self.get_headers().get(key.as_ref()) { + if let Some(values) = self.get_headers().get(&key.as_ref().to_ascii_lowercase()) { values.iter().any(|data: &String| data == value.as_ref()) } else { false @@ -1048,6 +1167,120 @@ impl Request { self.get_upgrade_type().is_ws() } + /// Checks whether the request declares a `multipart/form-data` body. + /// + /// # Returns + /// + /// - `bool` - Whether the request content type is `multipart/form-data`. + #[inline(always)] + pub fn is_multipart_form_data(&self) -> bool { + self.try_get_content_type() + .map(|content_type: String| MultipartFormData::is_form_data(content_type)) + .unwrap_or(false) + } + + /// Tries to get the request content type with its parameters stripped. + /// + /// A `Content-Type` header such as `text/html; charset=utf-8` yields the + /// media type alone, so a caller comparing against a media type does not + /// have to know that parameters may follow it. + /// + /// # Returns + /// + /// - `Option` - The media type, or `None` when the header is absent. + #[inline(always)] + pub fn try_get_content_type(&self) -> Option { + self.try_get_header_front(CONTENT_TYPE) + .map(|value: String| { + value + .split(SEMICOLON) + .next() + .unwrap_or(EMPTY_STR) + .trim() + .to_ascii_lowercase() + }) + } + + /// Tries to get one parameter of the request `Content-Type` header. + /// + /// The name is matched case-insensitively and a quoted value is unquoted, + /// so both `boundary=abc` and `boundary="abc"` yield `abc`. + /// + /// # Arguments + /// + /// - `K` - The parameter name, which requires the `AsRef` bound. + /// + /// # Returns + /// + /// - `Option` - The parameter value, or `None` when absent. + pub fn try_get_content_type_parameter(&self, name: K) -> Option + where + K: AsRef, + { + let content_type: String = self.try_get_header_front(CONTENT_TYPE)?; + let name: &str = name.as_ref(); + for parameter in content_type.split(SEMICOLON).skip(1) { + let (key, value) = parameter.trim().split_once(EQUAL)?; + if !key.trim().eq_ignore_ascii_case(name) { + continue; + } + let value: &str = value.trim(); + let value: &str = value + .strip_prefix(MULTIPART_PARAMETER_QUOTE) + .and_then(|value: &str| value.strip_suffix(MULTIPART_PARAMETER_QUOTE)) + .unwrap_or(value); + if !value.is_empty() { + return Some(value.to_string()); + } + } + None + } + + /// Tries to decode a `multipart/form-data` body into its parts. + /// + /// The boundary is read from the request `Content-Type` header rather than + /// guessed, so a body whose content type declares no boundary, a truncated + /// body, and a part without a name all yield `None`. + /// + /// # Returns + /// + /// - `Option` - The decoded parts, or `None` when unavailable. + pub fn try_get_body_form_data(&self) -> Option { + let boundary: MultipartFormDataBoundary = + self.try_get_content_type_parameter(MULTIPART_BOUNDARY_PARAMETER)?; + MultipartFormData::parse(self.get_body(), &boundary) + } + + /// Decodes an `application/x-www-form-urlencoded` body into its fields. + /// + /// # Returns + /// + /// - `BodyForm` - The decoded form fields. + #[inline(always)] + pub fn get_body_form(&self) -> BodyForm { + MultipartFormData::parse_url_encoded(self.get_body()) + } + + /// Tries to get a plain text field of a `multipart/form-data` body. + /// + /// A part that uploads a file is skipped, so a file field never silently + /// decodes as text. + /// + /// # Arguments + /// + /// - `K` - The form field name, which requires the `AsRef` bound. + /// + /// # Returns + /// + /// - `Option` - The field value, or `None` when absent or a file. + pub fn try_get_body_form_data_text(&self, name: K) -> Option + where + K: AsRef, + { + let parts: MultipartFormDataParts = self.try_get_body_form_data()?; + MultipartFormData::try_get_part_text(&parts, name) + } + /// Checks if the current upgrade type is HTTP/2 cleartext (h2c). /// /// # Returns diff --git a/type/src/request/struct.rs b/type/src/request/struct.rs index 8128eaad..c3483ec1 100644 --- a/type/src/request/struct.rs +++ b/type/src/request/struct.rs @@ -7,6 +7,9 @@ use super::*; /// when parsing HTTP requests. /// Since all fields implement `Copy`, this struct is lightweight /// and can be easily cloned and shared. +/// +/// The fields stay `pub`: `type/tests/request` reads them directly, so this is +/// published API and §17.14 does not recall it (semver). #[derive(Clone, Copy, Data, Debug, Deserialize, DisplayDebug, Eq, New, PartialEq, Serialize)] pub struct RequestConfig { /// Buffer size for reading operations. @@ -35,6 +38,9 @@ pub struct RequestConfig { /// HTTP request representation. /// /// Contains all components of an HTTP request. +/// +/// The fields stay `pub`: `type/tests/request` builds fixtures through them, so +/// this is published API and §17.14 does not recall it (semver). #[derive( Clone, Debug, Deserialize, DisplayDebug, Eq, Getter, GetterMut, PartialEq, Serialize, Setter, )] diff --git a/type/src/request/type.rs b/type/src/request/type.rs index 94ee21c2..c840b6f3 100644 --- a/type/src/request/type.rs +++ b/type/src/request/type.rs @@ -15,8 +15,15 @@ pub type RequestPath = String; /// An alias for `String`, representing the key type for request query parameters. pub type RequestQuerysKey = String; -/// An alias for `String`, representing the value type for request query parameters. -pub type RequestQuerysValue = String; +/// An alias for `String`, representing a single value of a request query parameter. +/// +/// A query key may repeat (`?tag=a&tag=b`), so every key maps to a collection +/// rather than to one value, which keeps the earlier values instead of dropping +/// them on the floor the way a single-value map would. +pub type RequestQuerysValueItem = String; + +/// An alias for `VecDeque`, representing every value of one query parameter. +pub type RequestQuerysValue = VecDeque; /// An alias for `HashMapXxHash3_64`, representing all query parameters parsed from the request URL. pub type RequestQuerys = HashMapXxHash3_64; diff --git a/type/src/response/impl.rs b/type/src/response/impl.rs index 584ac1ee..c29e26e2 100644 --- a/type/src/response/impl.rs +++ b/type/src/response/impl.rs @@ -397,7 +397,8 @@ impl Response { { let mut deque: VecDeque = VecDeque::with_capacity(1); deque.push_back(value.as_ref().to_owned()); - self.headers.insert(key.as_ref().to_owned(), deque); + self.get_mut_headers() + .insert(key.as_ref().to_owned(), deque); self } diff --git a/type/src/response/struct.rs b/type/src/response/struct.rs index 078ab15d..9f74ecd3 100644 --- a/type/src/response/struct.rs +++ b/type/src/response/struct.rs @@ -1,6 +1,9 @@ use super::*; /// Represents a parsed HTTP response. +/// +/// The fields stay `pub`: `type/tests/response` reads them directly, so this is +/// published API and §17.14 does not recall it (semver). #[derive( Clone, Debug, Deserialize, DisplayDebug, Eq, Getter, GetterMut, PartialEq, Serialize, Setter, )] diff --git a/type/src/stream/struct.rs b/type/src/stream/struct.rs index a9afd6a8..2ecfe664 100644 --- a/type/src/stream/struct.rs +++ b/type/src/stream/struct.rs @@ -21,25 +21,25 @@ pub struct Stream { #[derive(Data, New)] pub(crate) struct PooledReader<'a> { /// The underlying TCP stream being read. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] pub(super) stream: &'a mut TcpStream, /// The reusable read buffer holding unconsumed bytes in `start..end`. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] pub(super) buffer: Vec, /// The index of the first unconsumed byte in the buffer. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] #[new(skip)] pub(super) start: usize, /// The index one past the last valid byte in the buffer. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] + #[get(pub(super))] + #[get_mut(pub(super))] + #[set(pub(super))] #[new(skip)] pub(super) end: usize, } diff --git a/type/tests/body/fn.rs b/type/tests/body/fn.rs new file mode 100644 index 00000000..1f9dbccf --- /dev/null +++ b/type/tests/body/fn.rs @@ -0,0 +1,377 @@ +use super::*; + +#[test] +fn multipart_is_form_data() { + assert!(MultipartFormData::is_form_data( + "multipart/form-data; boundary=abc" + )); + assert!(MultipartFormData::is_form_data("MULTIPART/FORM-DATA")); + assert!(MultipartFormData::is_form_data(" multipart/form-data ")); + assert!(!MultipartFormData::is_form_data("application/json")); + assert!(!MultipartFormData::is_form_data("")); +} + +#[test] +fn multipart_get_boundary_unquoted() { + let boundary: Option = + MultipartFormData::get_boundary("multipart/form-data; boundary=----WebKitFormBoundary"); + assert_eq!(boundary, Some("----WebKitFormBoundary".to_string())); +} + +#[test] +fn multipart_get_boundary_quoted() { + let boundary: Option = + MultipartFormData::get_boundary("multipart/form-data; boundary=\"abc123\""); + assert_eq!(boundary, Some("abc123".to_string())); +} + +#[test] +fn multipart_get_boundary_case_insensitive_and_extra_parameters() { + let boundary: Option = + MultipartFormData::get_boundary("multipart/form-data; charset=utf-8; BOUNDARY=xyz"); + assert_eq!(boundary, Some("xyz".to_string())); +} + +#[test] +fn multipart_get_boundary_skips_parameter_without_equals() { + let boundary: Option = + MultipartFormData::get_boundary("multipart/form-data; broken; boundary=late"); + assert_eq!(boundary, Some("late".to_string())); +} + +#[test] +fn multipart_get_boundary_missing() { + let boundary: Option = + MultipartFormData::get_boundary("multipart/form-data"); + assert_eq!(boundary, None); + let boundary_empty: Option = + MultipartFormData::get_boundary("multipart/form-data; boundary="); + assert_eq!(boundary_empty, None); +} + +#[test] +fn multipart_parse_single_text_field() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"title\"\r\n", + "\r\n", + "hello world\r\n", + "--BOUND--\r\n" + ) + .to_string(); + let parts: MultipartFormDataParts = MultipartFormData::parse(body, "BOUND").unwrap(); + assert_eq!(parts.len(), 1); + let part: &BodyPart = &parts[0]; + assert_eq!(part.try_get_name(), Some(&"title".to_string())); + assert_eq!(part.try_get_file_name(), None); + assert_eq!(part.try_get_data(), Some(b"hello world".as_slice())); +} + +#[test] +fn multipart_parse_file_upload() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"upload\"; filename=\"a.txt\"\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "file body\r\n", + "--BOUND--\r\n" + ) + .to_string(); + let parts: MultipartFormDataParts = MultipartFormData::parse(body, "BOUND").unwrap(); + assert_eq!(parts.len(), 1); + let part: &BodyPart = &parts[0]; + assert_eq!(part.try_get_name(), Some(&"upload".to_string())); + assert_eq!(part.try_get_file_name(), Some(&"a.txt".to_string())); + assert_eq!(part.try_get_content_type(), Some(&"text/plain".to_string())); + assert!(part.try_get_header("content-type").is_some()); + assert_eq!(part.try_get_header("x-absent"), None); + assert_eq!(part.try_get_data(), Some(b"file body".as_slice())); +} + +#[test] +fn multipart_parse_multiple_parts() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"field\"\r\n", + "\r\n", + "value\r\n", + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"file\"; filename=\"b.bin\"\r\n", + "Content-Type: application/octet-stream\r\n", + "\r\n", + "\u{0}\u{1}\u{2}\r\n", + "--BOUND--\r\n" + ) + .to_string(); + let parts: MultipartFormDataParts = MultipartFormData::parse(body, "BOUND").unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].try_get_name(), Some(&"field".to_string())); + assert_eq!(parts[1].try_get_name(), Some(&"file".to_string())); + assert_eq!(parts[1].try_get_data(), Some([0u8, 1, 2].as_slice())); +} + +#[test] +fn multipart_parse_binary_payload_is_byte_exact() { + let mut body: Vec = Vec::new(); + body.extend_from_slice(b"--BOUND\r\n"); + body.extend_from_slice( + b"Content-Disposition: form-data; name=\"blob\"; filename=\"raw.bin\"\r\n\r\n", + ); + body.extend_from_slice(&[0u8, 13, 10, 255, 128, 0, 65]); + body.extend_from_slice(b"\r\n--BOUND--\r\n"); + let parts: MultipartFormDataParts = MultipartFormData::parse(&body, "BOUND").unwrap(); + assert_eq!(parts.len(), 1); + assert_eq!( + parts[0].try_get_data(), + Some([0u8, 13, 10, 255, 128, 0, 65].as_slice()) + ); +} + +#[test] +fn multipart_parse_accepts_bare_line_feeds() { + let body: String = concat!( + "--BOUND\n", + "Content-Disposition: form-data; name=\"title\"\n", + "\n", + "lenient\n", + "--BOUND--\n" + ) + .to_string(); + let parts: MultipartFormDataParts = MultipartFormData::parse(body, "BOUND").unwrap(); + assert_eq!(parts.len(), 1); + assert_eq!(parts[0].try_get_name(), Some(&"title".to_string())); +} + +#[test] +fn multipart_parse_ignores_boundary_like_inside_payload() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"text\"\r\n", + "\r\n", + "--BOUNDARY is not a boundary\r\n", + "--BOUND--\r\n" + ) + .to_string(); + let parts: MultipartFormDataParts = MultipartFormData::parse(body, "BOUND").unwrap(); + assert_eq!(parts.len(), 1); + assert_eq!( + parts[0].try_get_data(), + Some(b"--BOUNDARY is not a boundary".as_slice()) + ); +} + +#[test] +fn multipart_parse_header_keys_are_lowercased() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"title\"\r\n", + "X-Custom-Header: value\r\n", + "\r\n", + "data\r\n", + "--BOUND--\r\n" + ) + .to_string(); + let parts: MultipartFormDataParts = MultipartFormData::parse(body, "BOUND").unwrap(); + let part: &BodyPart = &parts[0]; + assert_eq!( + part.try_get_header("x-custom-header"), + Some(&"value".to_string()) + ); +} + +#[test] +fn multipart_parse_missing_boundary_returns_error() { + assert_eq!(MultipartFormData::parse("no boundary here", "BOUND"), None); +} + +#[test] +fn multipart_parse_missing_name_returns_error() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "data\r\n", + "--BOUND--\r\n" + ) + .to_string(); + assert_eq!(MultipartFormData::parse(body, "BOUND"), None); +} + +#[test] +fn multipart_parse_unterminated_closing_boundary_is_rejected() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"title\"\r\n", + "\r\n", + "value\r\n", + "--BOUND\r\n" + ) + .to_string(); + assert_eq!(MultipartFormData::parse(body, "BOUND"), None); +} + +#[test] +fn multipart_try_get_part() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"first\"\r\n", + "\r\n", + "one\r\n", + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"second\"\r\n", + "\r\n", + "two\r\n", + "--BOUND--\r\n" + ) + .to_string(); + let parts: MultipartFormDataParts = MultipartFormData::parse(body, "BOUND").unwrap(); + let found: Option<&BodyPart> = MultipartFormData::try_get_part(&parts, "second"); + assert_eq!( + found.and_then(|part: &BodyPart| part.try_get_text()), + Some("two".to_string()) + ); + assert!(MultipartFormData::try_get_part(&parts, "missing").is_none()); +} + +#[test] +fn multipart_try_get_part_text_skips_files() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"field\"\r\n", + "\r\n", + "plain value\r\n", + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"upload\"; filename=\"c.txt\"\r\n", + "\r\n", + "file content\r\n", + "--BOUND--\r\n" + ) + .to_string(); + let parts: MultipartFormDataParts = MultipartFormData::parse(body, "BOUND").unwrap(); + assert_eq!( + MultipartFormData::try_get_part_text(&parts, "field"), + Some("plain value".to_string()) + ); + assert_eq!(MultipartFormData::try_get_part_text(&parts, "upload"), None); + assert_eq!( + MultipartFormData::try_get_part_text(&parts, "missing"), + None + ); +} + +#[test] +fn body_part_default_is_empty() { + let part: BodyPart = BodyPart::default(); + assert_eq!(part.try_get_name(), None); + assert_eq!(part.try_get_file_name(), None); + assert_eq!(part.try_get_content_type(), None); + assert_eq!(part.try_get_header("x-none"), None); + assert_eq!(part.try_get_data(), None); +} + +#[test] +fn url_encoded_parse_basic_fields() { + let form: BodyForm = MultipartFormData::parse_url_encoded("a=1&b=2"); + assert_eq!(form.get("a"), Some(&"1".to_string())); + assert_eq!(form.get("b"), Some(&"2".to_string())); +} + +#[test] +fn url_encoded_parse_decodes_percent_and_plus() { + let form: BodyForm = + MultipartFormData::parse_url_encoded("msg=hello+world%21&name=%E4%B8%AD%E6%96%87"); + assert_eq!(form.get("msg"), Some(&"hello world!".to_string())); + assert_eq!(form.get("name"), Some(&"中文".to_string())); +} + +#[test] +fn url_encoded_parse_handles_empty_and_bare_keys() { + let form: BodyForm = MultipartFormData::parse_url_encoded("a=&b&&c=1"); + assert_eq!(form.get("a"), Some(&"".to_string())); + assert_eq!(form.get("b"), Some(&"".to_string())); + assert_eq!(form.get("c"), Some(&"1".to_string())); + assert_eq!(form.len(), 3); +} + +#[test] +fn url_encoded_parse_keeps_invalid_escape_verbatim() { + let form: BodyForm = MultipartFormData::parse_url_encoded("a=100%zz"); + assert_eq!(form.get("a"), Some(&"100%zz".to_string())); +} + +#[test] +fn url_encoded_parse_empty_body_is_empty_form() { + let form: BodyForm = MultipartFormData::parse_url_encoded(""); + assert!(form.is_empty()); +} + +#[test] +fn request_is_multipart_form_data_detects_content_type() { + let mut values: VecDeque = VecDeque::new(); + values.push_back("multipart/form-data; boundary=BOUND".to_string()); + let mut request: Request = Request::default(); + request + .get_mut_headers() + .insert(CONTENT_TYPE.to_string(), values); + assert!(request.is_multipart_form_data()); + assert!(!Request::default().is_multipart_form_data()); +} + +#[test] +fn request_try_get_body_form_data_decodes_parts() { + let body: String = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"title\"\r\n", + "\r\n", + "hello\r\n", + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"upload\"; filename=\"d.txt\"\r\n", + "\r\n", + "content\r\n", + "--BOUND--\r\n" + ) + .to_string(); + let mut values: VecDeque = VecDeque::new(); + values.push_back("multipart/form-data; boundary=BOUND".to_string()); + let mut request: Request = Request::default(); + request + .get_mut_headers() + .insert(CONTENT_TYPE.to_string(), values); + request.set_body(body.into_bytes()); + let parts: MultipartFormDataParts = request.try_get_body_form_data().unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!( + request.try_get_body_form_data_text("title"), + Some("hello".to_string()) + ); + assert_eq!(request.try_get_body_form_data_text("upload"), None); +} + +#[test] +fn request_try_get_body_form_data_without_content_type_is_error() { + let request: Request = Request::default(); + assert_eq!(request.try_get_body_form_data(), None); +} + +#[test] +fn request_try_get_body_form_data_without_boundary_is_error() { + let mut values: VecDeque = VecDeque::new(); + values.push_back("multipart/form-data".to_string()); + let mut request: Request = Request::default(); + request + .get_mut_headers() + .insert(CONTENT_TYPE.to_string(), values); + assert_eq!(request.try_get_body_form_data(), None); +} + +#[test] +fn request_get_body_form_decodes_url_encoded() { + let request: Request = Request { + body: "a=1&b=two".as_bytes().to_vec(), + ..Default::default() + }; + let form: BodyForm = request.get_body_form(); + assert_eq!(form.get("a"), Some(&"1".to_string())); + assert_eq!(form.get("b"), Some(&"two".to_string())); +} diff --git a/type/tests/body/mod.rs b/type/tests/body/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/type/tests/body/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/type/tests/mod.rs b/type/tests/mod.rs index 98b1880e..9cff6b24 100644 --- a/type/tests/mod.rs +++ b/type/tests/mod.rs @@ -2,6 +2,7 @@ mod any; mod arc_mutex; mod arc_rwlock; mod attribute; +mod body; mod box_leak; mod box_rwlock; mod content_type; diff --git a/type/tests/request/fn.rs b/type/tests/request/fn.rs index c006c8f6..3b1b7f69 100644 --- a/type/tests/request/fn.rs +++ b/type/tests/request/fn.rs @@ -51,16 +51,60 @@ fn request_default() { #[test] fn request_query_operations() { let mut request: Request = Request::default(); + let mut first: VecDeque = VecDeque::new(); + first.push_back("value1".to_string()); + request.get_mut_querys().insert("key1".to_string(), first); + let mut second: VecDeque = VecDeque::new(); + second.push_back("value2".to_string()); + request.get_mut_querys().insert("key2".to_string(), second); + assert_eq!( + request.try_get_query_front("key1"), + Some("value1".to_string()) + ); + assert_eq!( + request.try_get_query_front("key2"), + Some("value2".to_string()) + ); + assert_eq!(request.try_get_query_front("key3"), None); + assert_eq!(request.get_query_front("key1"), "value1".to_string()); +} + +#[test] +fn request_query_keeps_every_value_of_a_repeated_key() { + let mut request: Request = Request::default(); + let mut values: VecDeque = VecDeque::new(); + values.push_back("a".to_string()); + values.push_back("b".to_string()); + values.push_back("c".to_string()); + request.get_mut_querys().insert("tag".to_string(), values); + let all: RequestQuerysValue = request.get_query("tag"); + assert_eq!(all.len(), 3); + assert_eq!(request.try_get_query_size("tag"), Some(3)); + assert_eq!(request.try_get_query_front("tag"), Some("a".to_string())); + assert_eq!(request.try_get_query_back("tag"), Some("c".to_string())); + assert!(request.has_query("tag")); + assert!(!request.has_query("missing")); + assert_eq!(request.get_querys_size(), 1); +} + +#[test] +fn request_header_lookup_is_case_insensitive() { + let mut request: Request = Request::default(); + let mut values: VecDeque = VecDeque::new(); + values.push_back("text/plain".to_string()); request - .querys - .insert("key1".to_string(), "value1".to_string()); - request - .querys - .insert("key2".to_string(), "value2".to_string()); - assert_eq!(request.try_get_query("key1"), Some("value1".to_string())); - assert_eq!(request.try_get_query("key2"), Some("value2".to_string())); - assert_eq!(request.try_get_query("key3"), None); - assert_eq!(request.get_query("key1"), "value1".to_string()); + .get_mut_headers() + .insert("content-type".to_string(), values); + assert_eq!( + request.try_get_header("Content-Type"), + request.try_get_header("content-type") + ); + assert!(request.has_header("Content-Type")); + assert_eq!( + request.try_get_header_front("CONTENT-TYPE"), + Some("text/plain".to_string()) + ); + assert_eq!(request.try_get_header_size("Content-Type"), Some(1)); } #[test]