Commit Diff


commit - e7ae8fa0ff68290bf542ce584771be3c8b27ad01
commit + ab6adf11f97f5c70778cda19fdf34a15f4db3e59
blob - /dev/null
blob + d0803c7f5add4ef4a0a504b4b9ea81988b8bb739 (mode 644)
--- /dev/null
+++ examples/upload.rs
@@ -0,0 +1,68 @@
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+
+//! Upload a file via multipart/form-data and print the response.
+//!
+//! Usage: ex-upload <url> <field> <path> [content-type]
+//!
+//! The default content type is application/octet-stream.
+
+use std::env;
+use std::process;
+
+fn main() {
+    let args: Vec<String> = env::args().collect();
+    if args.len() < 4 || args.len() > 5 {
+        eprintln!("usage: ex-upload <url> <field> <path> [content-type]");
+        process::exit(1);
+    }
+    let url = &args[1];
+    let field = &args[2];
+    let path = &args[3];
+    let ct = args
+        .get(4)
+        .map(String::as_str)
+        .unwrap_or("application/octet-stream");
+
+    let form = match http::Multipart::new().file_path(field, path, ct) {
+        Ok(f) => f,
+        Err(e) => {
+            eprintln!("ex-upload: {e}");
+            process::exit(2);
+        }
+    };
+
+    let resp = match http::post(url).multipart(form).send() {
+        Ok(r) => r,
+        Err(e) => {
+            eprintln!("ex-upload: {e}");
+            process::exit(2);
+        }
+    };
+    println!("{} {} {}", resp.version, resp.status, resp.reason);
+    for (name, value) in resp.headers.iter() {
+        println!("{name}: {value}");
+    }
+    println!();
+    match resp.body_string() {
+        Ok(s) => print!("{s}"),
+        Err(e) => {
+            eprintln!("ex-upload: read body: {e}");
+            process::exit(2);
+        }
+    }
+}
blob - 9bc68ece720239d874c3dbe21714cb39c6d9aba0
blob + 298a17645c08fc2a0326c40e97efe0179afffdb8
--- http.rs
+++ http.rs
@@ -1329,6 +1329,16 @@ impl RequestBuilder {
         self
     }
 
+    /// Set the body to a `multipart/form-data` payload (RFC 2388) and
+    /// the Content-Type header (with boundary).  Overwrites any previous
+    /// body and Content-Type.
+    pub fn multipart(mut self, form: Multipart) -> Self {
+        let ct = form.content_type();
+        self.body = form.into_bytes();
+        self.headers.set("Content-Type", ct);
+        self
+    }
+
     /// Override the read/write timeout.  Default is 30 seconds.
     pub fn timeout(mut self, timeout: Duration) -> Self {
         self.timeout = timeout;
@@ -1503,6 +1513,165 @@ pub fn url_form(pairs: &[(&str, &str)]) -> String {
 }
 
 //////////////////////////////////////////////////////////////////////////////
+// multipart/form-data (RFC 2388)
+//////////////////////////////////////////////////////////////////////////////
+
+/// A `multipart/form-data` body builder.  RFC 2388.
+///
+/// Each part has a name and either a text value or a file payload
+/// (filename + content-type + bytes).  Pass the finished form to
+/// [`RequestBuilder::multipart`], which sets the Content-Type header
+/// (with the boundary) and serializes the body.
+#[derive(Clone, Debug)]
+pub struct Multipart {
+    boundary: String,
+    parts: Vec<Part>,
+}
+
+#[derive(Clone, Debug)]
+struct Part {
+    name: String,
+    filename: Option<String>,
+    content_type: Option<String>,
+    body: Vec<u8>,
+}
+
+impl Multipart {
+    /// Create a new form with a fresh boundary.
+    pub fn new() -> Self {
+        Self {
+            boundary: gen_boundary(),
+            parts: Vec::new(),
+        }
+    }
+
+    /// Append a text field.
+    pub fn text(
+        mut self,
+        name: impl Into<String>,
+        value: impl Into<String>,
+    ) -> Self {
+        self.parts.push(Part {
+            name: name.into(),
+            filename: None,
+            content_type: None,
+            body: value.into().into_bytes(),
+        });
+        self
+    }
+
+    /// Append a file field with explicit filename, content type, and bytes.
+    pub fn file(
+        mut self,
+        name: impl Into<String>,
+        filename: impl Into<String>,
+        content_type: impl Into<String>,
+        body: impl Into<Vec<u8>>,
+    ) -> Self {
+        self.parts.push(Part {
+            name: name.into(),
+            filename: Some(filename.into()),
+            content_type: Some(content_type.into()),
+            body: body.into(),
+        });
+        self
+    }
+
+    /// Append a file field by reading `path` from disk.  The filename
+    /// sent is the path's final component.
+    pub fn file_path(
+        self,
+        name: impl Into<String>,
+        path: impl AsRef<std::path::Path>,
+        content_type: impl Into<String>,
+    ) -> Result<Self> {
+        let path = path.as_ref();
+        let body = std::fs::read(path)
+            .map_err(|e| Error::new(format!("read {}: {e}", path.display())))?;
+        let filename = path
+            .file_name()
+            .and_then(|s| s.to_str())
+            .ok_or_else(|| Error::new("path has no file name"))?
+            .to_owned();
+        Ok(self.file(name, filename, content_type, body))
+    }
+
+    /// The boundary string (without the leading dashes).
+    pub fn boundary(&self) -> &str {
+        &self.boundary
+    }
+
+    /// Value to use in the `Content-Type` header.
+    pub fn content_type(&self) -> String {
+        format!("multipart/form-data; boundary={}", self.boundary)
+    }
+
+    /// Serialize the form body to bytes.
+    pub fn into_bytes(self) -> Vec<u8> {
+        let mut out = Vec::new();
+        self.write_to(&mut out).expect("Vec write is infallible");
+        out
+    }
+
+    /// Write the serialized form body to `w`.
+    pub fn write_to(&self, w: &mut impl Write) -> io::Result<()> {
+        for part in &self.parts {
+            write!(w, "--{}\r\n", self.boundary)?;
+            w.write_all(b"Content-Disposition: form-data; name=\"")?;
+            write_quoted(w, &part.name)?;
+            w.write_all(b"\"")?;
+            if let Some(filename) = &part.filename {
+                w.write_all(b"; filename=\"")?;
+                write_quoted(w, filename)?;
+                w.write_all(b"\"")?;
+            }
+            w.write_all(b"\r\n")?;
+            if let Some(ct) = &part.content_type {
+                write!(w, "Content-Type: {ct}\r\n")?;
+            }
+            w.write_all(b"\r\n")?;
+            w.write_all(&part.body)?;
+            w.write_all(b"\r\n")?;
+        }
+        write!(w, "--{}--\r\n", self.boundary)
+    }
+}
+
+impl Default for Multipart {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+/// Backslash-escape `"` and `\` per RFC 2616 §2.2 quoted-string.  CR/LF
+/// are not legal inside a quoted-string, so they are dropped.
+fn write_quoted(w: &mut impl Write, s: &str) -> io::Result<()> {
+    for &b in s.as_bytes() {
+        match b {
+            b'"' | b'\\' => w.write_all(&[b'\\', b])?,
+            b'\r' | b'\n' => continue,
+            _ => w.write_all(&[b])?,
+        }
+    }
+    Ok(())
+}
+
+/// A boundary that is unique within a process: nanos + pid + counter,
+/// as 70-bchar-safe hex.  RFC 2046 §5.1.1 only requires uniqueness
+/// against the body, not unpredictability.
+fn gen_boundary() -> String {
+    use std::sync::atomic::{AtomicU64, Ordering};
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
+    let nanos = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_nanos() as u64)
+        .unwrap_or(0);
+    let pid = std::process::id() as u64;
+    format!("----http-rs-{nanos:016x}-{pid:08x}-{n:08x}")
+}
+
+//////////////////////////////////////////////////////////////////////////////
 // Tests
 //////////////////////////////////////////////////////////////////////////////
 
@@ -1808,4 +1977,67 @@ mod tests {
         assert!(!is_final_chunked("chunked, gzip"));
         assert!(!is_final_chunked("gzip"));
     }
+
+    #[test]
+    fn multipart_format() {
+        let form = Multipart::new().text("name", "Murilo").file(
+            "upload",
+            "hello.txt",
+            "text/plain",
+            b"hello".to_vec(),
+        );
+        let boundary = form.boundary().to_owned();
+        let s = String::from_utf8(form.into_bytes()).unwrap();
+        let expected = format!(
+            "--{b}\r\n\
+             Content-Disposition: form-data; name=\"name\"\r\n\
+             \r\n\
+             Murilo\r\n\
+             --{b}\r\n\
+             Content-Disposition: form-data; name=\"upload\"; \
+             filename=\"hello.txt\"\r\n\
+             Content-Type: text/plain\r\n\
+             \r\n\
+             hello\r\n\
+             --{b}--\r\n",
+            b = boundary,
+        );
+        assert_eq!(s, expected);
+    }
+
+    #[test]
+    fn multipart_content_type_header() {
+        let form = Multipart::new();
+        let ct = form.content_type();
+        let prefix = "multipart/form-data; boundary=";
+        assert!(ct.starts_with(prefix));
+        assert_eq!(&ct[prefix.len()..], form.boundary());
+    }
+
+    #[test]
+    fn multipart_quotes_special_chars() {
+        let form = Multipart::new().file(
+            "f",
+            "a\"b\\c.txt",
+            "application/octet-stream",
+            b"x".to_vec(),
+        );
+        let s = String::from_utf8(form.into_bytes()).unwrap();
+        assert!(s.contains("filename=\"a\\\"b\\\\c.txt\""));
+    }
+
+    #[test]
+    fn multipart_unique_boundaries() {
+        let a = Multipart::new();
+        let b = Multipart::new();
+        assert_ne!(a.boundary(), b.boundary());
+    }
+
+    #[test]
+    fn multipart_empty_form() {
+        let form = Multipart::new();
+        let boundary = form.boundary().to_owned();
+        let s = String::from_utf8(form.into_bytes()).unwrap();
+        assert_eq!(s, format!("--{boundary}--\r\n"));
+    }
 }