Skip to content

Commit 191b952

Browse files
committed
feat(client): add RFC 2387 multipart/related support
Add runtime support for multipart/related requests following RFC 2387. Introduces MultipartPart struct, MultipartRelatedBody trait, and RequestBuilderExt::multipart_related() method for constructing properly formatted multipart/related request bodies. The implementation includes unique boundary generation using timestamp and atomic counter, proper Content-Type and Content-ID headers for each part, and correct CRLF formatting per RFC 2046/2387. This provides the runtime foundation for multipart file uploads with metadata in a single request body. Refs: #1240
1 parent d3609d7 commit 191b952

1 file changed

Lines changed: 69 additions & 0 deletions

File tree

progenitor-client/src/progenitor_client.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,9 +526,22 @@ pub fn encode_path(pc: &str) -> String {
526526
percent_encoding::utf8_percent_encode(pc, PATH_SET).to_string()
527527
}
528528

529+
#[doc(hidden)]
530+
pub struct MultipartPart<'a> {
531+
pub content_type: &'static str,
532+
pub content_id: &'a str,
533+
pub bytes: Vec<u8>,
534+
}
535+
536+
#[doc(hidden)]
537+
pub trait MultipartRelatedBody {
538+
fn as_multipart_parts(&self) -> Vec<MultipartPart<'_>>;
539+
}
540+
529541
#[doc(hidden)]
530542
pub trait RequestBuilderExt<E> {
531543
fn form_urlencoded<T: Serialize + ?Sized>(self, body: &T) -> Result<RequestBuilder, Error<E>>;
544+
fn multipart_related<T: MultipartRelatedBody + ?Sized>(self, body: &T) -> Result<RequestBuilder, Error<E>>;
532545
}
533546

534547
impl<E> RequestBuilderExt<E> for RequestBuilder {
@@ -543,6 +556,62 @@ impl<E> RequestBuilderExt<E> for RequestBuilder {
543556
.map_err(|_| Error::InvalidRequest("failed to serialize body".to_string()))?,
544557
))
545558
}
559+
560+
fn multipart_related<T: MultipartRelatedBody + ?Sized>(self, body: &T) -> Result<Self, Error<E>> {
561+
// Generate a unique boundary
562+
let boundary = generate_multipart_boundary();
563+
564+
// Get the parts from the body
565+
let parts = body.as_multipart_parts();
566+
567+
let mut body_bytes = Vec::new();
568+
569+
// Write each part
570+
for part in parts {
571+
// Write boundary
572+
body_bytes.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
573+
574+
// Write Content-Type header
575+
body_bytes.extend_from_slice(format!("Content-Type: {}\r\n", part.content_type).as_bytes());
576+
577+
// Write Content-ID header
578+
body_bytes.extend_from_slice(format!("Content-ID: <{}>\r\n\r\n", part.content_id).as_bytes());
579+
580+
// Write content bytes
581+
body_bytes.extend_from_slice(&part.bytes);
582+
583+
// Write CRLF after content
584+
body_bytes.extend_from_slice(b"\r\n");
585+
}
586+
587+
// Write closing boundary
588+
body_bytes.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
589+
590+
// Set Content-Type header with boundary
591+
let content_type = format!("multipart/related; boundary={}; type=application/json", boundary);
592+
593+
Ok(self
594+
.header(
595+
reqwest::header::CONTENT_TYPE,
596+
reqwest::header::HeaderValue::from_str(&content_type)
597+
.map_err(|e| Error::InvalidRequest(format!("invalid content-type header: {}", e)))?,
598+
)
599+
.body(body_bytes))
600+
}
601+
}
602+
603+
fn generate_multipart_boundary() -> String {
604+
use std::sync::atomic::{AtomicU64, Ordering};
605+
static COUNTER: AtomicU64 = AtomicU64::new(0);
606+
607+
// Generate a unique boundary using timestamp and counter
608+
let count = COUNTER.fetch_add(1, Ordering::SeqCst);
609+
let timestamp = std::time::SystemTime::now()
610+
.duration_since(std::time::UNIX_EPOCH)
611+
.map(|d| d.as_millis())
612+
.unwrap_or(0);
613+
614+
format!("progenitor_boundary_{}_{}", timestamp, count)
546615
}
547616

548617
#[doc(hidden)]

0 commit comments

Comments
 (0)