use crate::error::{MultipartContentLengthSnafu, Result};
use async_std::fs::File;
use std::fmt;
use std::path::PathBuf;
pub enum Body {
Str(String),
Stream(Box<dyn async_std::io::Read + Unpin + Send + Sync>),
Bytes(Vec<u8>),
MultipartForm(BodyMultipartForm),
None,
}
impl fmt::Debug for Body {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Body::Str(s) => f.debug_tuple("Str").field(&s.len()).finish(),
Body::Stream(_) => f.debug_tuple("Stream").finish(),
Body::Bytes(b) => f.debug_tuple("Bytes").field(&b.len()).finish(),
Body::MultipartForm(form) => f
.debug_tuple("MultipartForm")
.field(&form.fields.len())
.finish(),
Body::None => f.debug_tuple("None").finish(),
}
}
}
#[derive(Clone, Default)]
pub struct BodyForm {
fields: Vec<(String, String)>,
}
impl BodyForm {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn add(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
self.fields
.push((key.as_ref().to_owned(), value.as_ref().to_owned()));
self
}
#[must_use]
pub fn serialize(&self) -> String {
self.fields
.iter()
.map(|(key, value)| format!("{}={}", url_encode(key), url_encode(value)))
.collect::<Vec<_>>()
.join("&")
}
#[must_use]
pub fn len(&self) -> usize {
self.fields.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
}
impl fmt::Debug for BodyForm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BodyForm")
.field("fields", &self.fields)
.finish()
}
}
fn url_encode(s: &str) -> String {
let mut result = String::new();
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(byte as char);
}
b' ' => {
result.push('+');
}
b => {
let high = (b >> 4) & 0x0f;
let low = b & 0x0f;
let hex = |n: u8| if n < 10 { b'0' + n } else { b'A' + n - 10 };
result.push('%');
result.push(hex(high) as char);
result.push(hex(low) as char);
}
}
}
result
}
pub enum MultipartField {
Text(String, String),
FilePath(String, PathBuf, Option<String>, Option<String>),
File(String, File, Option<String>, Option<String>),
Stream(
String,
Box<dyn async_std::io::Read + Unpin + Send + Sync>,
Option<String>,
Option<String>,
),
}
pub struct BodyMultipartForm {
pub(crate) fields: Vec<MultipartField>,
pub(crate) boundary: String,
}
impl BodyMultipartForm {
#[must_use]
pub fn new() -> Self {
Self {
fields: Vec::new(),
boundary: generate_boundary(),
}
}
#[must_use]
pub fn add(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
self.fields.push(MultipartField::Text(
name.as_ref().to_owned(),
value.as_ref().to_owned(),
));
self
}
pub fn add_file_path(
mut self, name: impl AsRef<str>, path: impl AsRef<std::path::Path>,
) -> Result<Self> {
let path = PathBuf::from(path.as_ref());
self.fields.push(MultipartField::FilePath(
name.as_ref().to_owned(),
path,
None, None, ));
Ok(self)
}
pub fn add_file_path_with_options(
mut self, name: impl AsRef<str>, path: impl AsRef<std::path::Path>,
filename: Option<impl AsRef<str>>, content_type: Option<impl AsRef<str>>,
) -> Result<Self> {
let path = PathBuf::from(path.as_ref());
self.fields.push(MultipartField::FilePath(
name.as_ref().to_owned(),
path,
filename.map(|f| f.as_ref().to_owned()),
content_type.map(|c| c.as_ref().to_owned()),
));
Ok(self)
}
#[must_use]
pub fn add_file(mut self, name: impl AsRef<str>, file: File) -> Self {
self.fields.push(MultipartField::File(
name.as_ref().to_owned(),
file,
None, None, ));
self
}
#[must_use]
pub fn add_file_with_options(
mut self, name: impl AsRef<str>, file: File, filename: Option<impl AsRef<str>>,
content_type: Option<impl AsRef<str>>,
) -> Self {
self.fields.push(MultipartField::File(
name.as_ref().to_owned(),
file,
filename.map(|f| f.as_ref().to_owned()),
content_type.map(|c| c.as_ref().to_owned()),
));
self
}
#[must_use]
pub fn add_stream(
mut self, name: impl AsRef<str>,
stream: Box<dyn async_std::io::Read + Unpin + Send + Sync>,
filename: Option<impl AsRef<str>>, content_type: Option<impl AsRef<str>>,
) -> Self {
self.fields.push(MultipartField::Stream(
name.as_ref().to_owned(),
stream,
filename.map(|f| f.as_ref().to_owned()),
content_type.map(|c| c.as_ref().to_owned()),
));
self
}
#[must_use]
pub fn boundary(&self) -> &str {
&self.boundary
}
#[must_use]
pub fn len(&self) -> usize {
self.fields.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
#[must_use]
pub fn has_stream_field(&self) -> bool {
self.fields
.iter()
.any(|f| matches!(f, MultipartField::Stream(..)))
}
pub async fn compute_content_length(&self) -> Result<u64> {
let boundary = &self.boundary;
let mut total: u64 = 0;
for field in &self.fields {
total += 2 + boundary.len() as u64 + 2;
match field {
MultipartField::Text(name, value) => {
total += format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n", name)
.len() as u64;
total += value.len() as u64;
total += 2; }
MultipartField::FilePath(name, path, filename_opt, content_type_opt) => {
let filename = filename_opt.as_deref().unwrap_or_else(|| {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("filename")
});
let content_type = content_type_opt
.as_deref()
.unwrap_or_else(|| detect_mime_type(filename));
total += format!(
"Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n",
name, filename
)
.len() as u64;
total += format!("Content-Type: {}\r\n\r\n", content_type).len() as u64;
let meta = async_std::fs::metadata(path).await.map_err(|e| {
MultipartContentLengthSnafu {
message: format!("cannot read metadata for {:?}: {e}", path),
}
.build()
})?;
total += meta.len();
total += 2; }
MultipartField::File(name, file, filename_opt, content_type_opt) => {
let filename = filename_opt.as_deref().unwrap_or("filename");
let content_type = content_type_opt
.as_deref()
.unwrap_or_else(|| detect_mime_type(filename));
total += format!(
"Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n",
name, filename
)
.len() as u64;
total += format!("Content-Type: {}\r\n\r\n", content_type).len() as u64;
let meta = file.metadata().await.map_err(|e| {
MultipartContentLengthSnafu {
message: format!("cannot read file metadata: {e}"),
}
.build()
})?;
total += meta.len();
total += 2; }
MultipartField::Stream(..) => {
return Err(MultipartContentLengthSnafu {
message:
"cannot compute content-length for Stream fields; use chunked encoding"
.to_string(),
}
.build());
}
}
}
total += 2 + boundary.len() as u64 + 2 + 2;
Ok(total)
}
}
impl Default for BodyMultipartForm {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for BodyMultipartForm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BodyMultipartForm")
.field("boundary", &self.boundary)
.field("fields_count", &self.fields.len())
.finish()
}
}
fn generate_boundary() -> String {
use rand::Rng;
let id: u64 = rand::rng().random();
format!("----Boundary{id:016x}")
}
pub fn detect_mime_type(filename: &str) -> &'static str {
if let Some(ext) = filename.rsplit('.').next() {
match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"txt" => "text/plain",
"html" | "htm" => "text/html",
"css" => "text/css",
"js" => "application/javascript",
"json" => "application/json",
"xml" => "application/xml",
"zip" => "application/zip",
"rar" => "application/vnd.rar",
"tar" => "application/x-tar",
"gz" => "application/gzip",
"mp3" => "audio/mpeg",
"mp4" => "video/mp4",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"webm" => "video/webm",
"doc" | "docx" => "application/msword",
"xls" | "xlsx" => "application/vnd.ms-excel",
"ppt" | "pptx" => "application/vnd.ms-powerpoint",
_ => "application/octet-stream",
}
} else {
"application/octet-stream"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_body_form_new() {
let form = BodyForm::new();
assert!(form.is_empty());
assert_eq!(form.len(), 0);
}
#[test]
fn test_body_form_add() {
let form = BodyForm::new()
.add("username", "alice")
.add("password", "secret");
assert_eq!(form.len(), 2);
assert!(!form.is_empty());
}
#[test]
fn test_body_form_serialize_simple() {
let form = BodyForm::new()
.add("username", "alice")
.add("password", "secret");
let serialized = form.serialize();
assert!(serialized.contains("username=alice"));
assert!(serialized.contains("password=secret"));
}
#[test]
fn test_body_form_serialize_with_spaces() {
let form = BodyForm::new().add("message", "hello world");
let serialized = form.serialize();
assert_eq!(serialized, "message=hello+world");
}
#[test]
fn test_body_form_serialize_with_special_chars() {
let form = BodyForm::new()
.add("email", "user@example.com")
.add("path", "/a/b/c");
let serialized = form.serialize();
assert!(serialized.contains("email=user%40example.com"));
assert!(serialized.contains("path=%2Fa%2Fb%2Fc"));
}
#[test]
fn test_body_form_duplicate_keys() {
let form = BodyForm::new()
.add("tags", "rust")
.add("tags", "http")
.add("tags", "async");
let serialized = form.serialize();
assert_eq!(serialized, "tags=rust&tags=http&tags=async");
}
#[test]
fn test_body_form_chainable() {
let form = BodyForm::new().add("a", "1").add("b", "2").add("c", "3");
assert_eq!(form.len(), 3);
}
#[test]
fn test_url_encode_unreserved() {
assert_eq!(url_encode("abc123-_.~"), "abc123-_.~");
}
#[test]
fn test_url_encode_space() {
assert_eq!(url_encode("hello world"), "hello+world");
}
#[test]
fn test_url_encode_special() {
assert_eq!(url_encode("user@example.com"), "user%40example.com");
assert_eq!(url_encode("/path/to/file"), "%2Fpath%2Fto%2Ffile");
assert_eq!(url_encode("query=value"), "query%3Dvalue");
}
#[test]
fn test_url_encode_unicode() {
let encoded = url_encode("你好");
assert!(encoded.starts_with("%"));
assert!(encoded.contains("%"));
}
}