sendra_core/request/multipart.rs
1//! [`MultipartPart`] and the hand-rolled `multipart/form-data` encoder
2//! [`crate::Request::resolve_body`] uses for [`crate::Request::multipart`].
3
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::SendraError;
9
10/// One part of a [`crate::Request::multipart`] body: either inline text (`value`) or
11/// a file (`path`), never both and never neither — enforced by
12/// [`crate::Request::validate`].
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[serde(deny_unknown_fields)]
16pub struct MultipartPart {
17 pub name: String,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub value: Option<String>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub path: Option<String>,
22}
23
24/// Read a `body_file` (or a multipart file part) relative to `base_dir`, as
25/// UTF-8 text.
26///
27/// A non-UTF-8 file surfaces as [`SendraError::BodyFileIo`] wrapping an
28/// `InvalidData` error, matching what `std::fs::read_to_string` itself
29/// returns for the same failure, rather than a silent lossy conversion —
30/// unlike a *response* body, which Sendra has never promised to send
31/// unmodified.
32pub(crate) fn read_body_file(base_dir: &Path, path: &str) -> Result<String, SendraError> {
33 let full_path = base_dir.join(path);
34 std::fs::read_to_string(&full_path).map_err(|source| SendraError::BodyFileIo {
35 path: full_path,
36 source,
37 })
38}
39
40/// Encode a `multipart` body by hand, as `multipart/form-data` text, and
41/// return it along with the `Content-Type` (boundary included) it implies.
42///
43/// Not built with `reqwest::multipart::Form`: that type holds arbitrary
44/// bytes and cannot be cloned, `PartialEq`d or serialized, none of which
45/// [`crate::Request`] can give up — it is `Clone`, `PartialEq`, `Serialize` and
46/// `Deserialize` throughout, including in the config/substitution/scripting
47/// pipeline a multipart request passes through like any other. Writing the
48/// format directly keeps the whole body a `String`, consistent with
49/// [`resolve_body`](crate::Request::resolve_body)'s UTF-8-text rule for
50/// `body_file`.
51///
52/// The boundary is derived from the current time, which is unique enough
53/// per-request for a boundary's actual job: a delimiter unlikely to occur
54/// inside any part's own content, not a cryptographic guarantee.
55pub(crate) fn encode_multipart(
56 parts: &[MultipartPart],
57 base_dir: &Path,
58) -> Result<(String, String), SendraError> {
59 let boundary = format!(
60 "----sendra-{:x}",
61 std::time::SystemTime::now()
62 .duration_since(std::time::UNIX_EPOCH)
63 .unwrap_or_default()
64 .as_nanos()
65 );
66
67 let mut body = String::new();
68 for part in parts {
69 body.push_str("--");
70 body.push_str(&boundary);
71 body.push_str("\r\n");
72 match (&part.value, &part.path) {
73 (Some(value), None) => {
74 body.push_str(&format!(
75 "Content-Disposition: form-data; name=\"{}\"\r\n\r\n",
76 part.name
77 ));
78 body.push_str(value);
79 }
80 (None, Some(path)) => {
81 let filename = Path::new(path)
82 .file_name()
83 .and_then(|name| name.to_str())
84 .unwrap_or(path);
85 body.push_str(&format!(
86 "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n\r\n",
87 part.name, filename
88 ));
89 body.push_str(&read_body_file(base_dir, path)?);
90 }
91 // Ruled out by `Request::validate` before `resolve_body` is ever
92 // reached; kept exhaustive rather than `unreachable!()` so a
93 // future caller of `encode_multipart` that skips validation gets
94 // an empty part instead of a panic.
95 (Some(_), Some(_)) | (None, None) => {}
96 }
97 body.push_str("\r\n");
98 }
99 body.push_str("--");
100 body.push_str(&boundary);
101 body.push_str("--\r\n");
102
103 let content_type = format!("multipart/form-data; boundary={boundary}");
104 Ok((body, content_type))
105}