axum_extra/response/multiple.rs
1//! Generate forms to use in responses.
2
3use axum_core::response::{IntoResponse, Response};
4use fastrand;
5use http::{header, HeaderMap, StatusCode};
6use mime::Mime;
7
8/// Create multipart forms to be used in API responses.
9///
10/// This struct implements [`IntoResponse`], and so it can be returned from a handler.
11#[must_use]
12#[derive(Debug)]
13pub struct MultipartForm {
14 parts: Vec<Part>,
15}
16
17impl MultipartForm {
18 /// Initialize a new multipart form with the provided vector of parts.
19 ///
20 /// # Examples
21 ///
22 /// ```rust
23 /// use axum_extra::response::multiple::{MultipartForm, Part};
24 ///
25 /// let parts: Vec<Part> = vec![Part::text("foo".to_string(), "abc"), Part::text("bar".to_string(), "def")];
26 /// let form = MultipartForm::with_parts(parts);
27 /// ```
28 pub fn with_parts(parts: Vec<Part>) -> Self {
29 MultipartForm { parts }
30 }
31}
32
33impl IntoResponse for MultipartForm {
34 fn into_response(self) -> Response {
35 // see RFC5758 for details
36 let boundary = generate_boundary();
37 let mut headers = HeaderMap::new();
38 let mime_type: Mime = match format!("multipart/form-data; boundary={boundary}").parse() {
39 Ok(m) => m,
40 // Realistically this should never happen unless the boundary generation code
41 // is modified, and that will be caught by unit tests
42 Err(_) => {
43 return (
44 StatusCode::INTERNAL_SERVER_ERROR,
45 "Invalid multipart boundary generated",
46 )
47 .into_response()
48 }
49 };
50 // The use of unwrap is safe here because mime types are inherently string representable
51 headers.insert(header::CONTENT_TYPE, mime_type.to_string().parse().unwrap());
52 let mut serialized_form: Vec<u8> = Vec::new();
53 for part in self.parts {
54 // for each part, the boundary is preceded by two dashes
55 serialized_form.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
56 serialized_form.extend_from_slice(&part.serialize());
57 }
58 serialized_form.extend_from_slice(format!("--{boundary}--").as_bytes());
59 (headers, serialized_form).into_response()
60 }
61}
62
63// Valid settings for that header are: "base64", "quoted-printable", "8bit", "7bit", and "binary".
64/// A single part of a multipart form as defined by
65/// <https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4>
66/// and RFC5758.
67#[derive(Debug)]
68pub struct Part {
69 // Every part is expected to contain:
70 // - a [Content-Disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
71 // header, where `Content-Disposition` is set to `form-data`, with a parameter of `name` that is set to
72 // the name of the field in the form. In the below example, the name of the field is `user`:
73 // ```
74 // Content-Disposition: form-data; name="user"
75 // ```
76 // If the field contains a file, then the `filename` parameter may be set to the name of the file.
77 // Handling for non-ascii field names is not done here, support for non-ascii characters may be encoded using
78 // methodology described in RFC 2047.
79 // - (optionally) a `Content-Type` header, which if not set, defaults to `text/plain`.
80 // If the field contains a file, then the file should be identified with that file's MIME type (eg: `image/gif`).
81 // If the `MIME` type is not known or specified, then the MIME type should be set to `application/octet-stream`.
82 /// The name of the part in question
83 name: String,
84 /// If the part should be treated as a file, the filename that should be attached that part
85 filename: Option<String>,
86 /// The `Content-Type` header. While not strictly required, it is always set here
87 mime_type: Mime,
88 /// The content/body of the part
89 contents: Vec<u8>,
90}
91
92impl Part {
93 /// Create a new part with `Content-Type` of `text/plain` with the supplied name and contents.
94 ///
95 /// This form will not have a defined file name.
96 ///
97 /// # Examples
98 ///
99 /// ```rust
100 /// use axum_extra::response::multiple::{MultipartForm, Part};
101 ///
102 /// // create a form with a single part that has a field with a name of "foo",
103 /// // and a value of "abc"
104 /// let parts: Vec<Part> = vec![Part::text("foo".to_string(), "abc")];
105 /// let form = MultipartForm::from_iter(parts);
106 /// ```
107 #[must_use]
108 pub fn text(name: String, contents: &str) -> Self {
109 Self {
110 name,
111 filename: None,
112 mime_type: mime::TEXT_PLAIN_UTF_8,
113 contents: contents.as_bytes().to_vec(),
114 }
115 }
116
117 /// Create a new part containing a generic file, with a `Content-Type` of `application/octet-stream`
118 /// using the provided file name, field name, and contents.
119 ///
120 /// If the MIME type of the file is known, consider using `Part::raw_part`.
121 ///
122 /// # Examples
123 ///
124 /// ```rust
125 /// use axum_extra::response::multiple::{MultipartForm, Part};
126 ///
127 /// // create a form with a single part that has a field with a name of "foo",
128 /// // with a file name of "foo.txt", and with the specified contents
129 /// let parts: Vec<Part> = vec![Part::file("foo", "foo.txt", vec![0x68, 0x68, 0x20, 0x6d, 0x6f, 0x6d])];
130 /// let form = MultipartForm::from_iter(parts);
131 /// ```
132 #[must_use]
133 pub fn file(field_name: &str, file_name: &str, contents: Vec<u8>) -> Self {
134 Self {
135 name: field_name.to_owned(),
136 filename: Some(file_name.to_owned()),
137 // If the `MIME` type is not known or specified, then the MIME type should be set to `application/octet-stream`.
138 // See RFC2388 section 3 for specifics.
139 mime_type: mime::APPLICATION_OCTET_STREAM,
140 contents,
141 }
142 }
143
144 /// Create a new part with more fine-grained control over the semantics of that part.
145 ///
146 /// The caller is assumed to have set a valid MIME type.
147 ///
148 /// This function will return an error if the provided MIME type is not valid.
149 ///
150 /// # Examples
151 ///
152 /// ```rust
153 /// use axum_extra::response::multiple::{MultipartForm, Part};
154 ///
155 /// // create a form with a single part that has a field with a name of "part_name",
156 /// // with a MIME type of "application/json", and the supplied contents.
157 /// let parts: Vec<Part> = vec![Part::raw_part("part_name", "application/json", vec![0x68, 0x68, 0x20, 0x6d, 0x6f, 0x6d], None).expect("MIME type must be valid")];
158 /// let form = MultipartForm::from_iter(parts);
159 /// ```
160 pub fn raw_part(
161 name: &str,
162 mime_type: &str,
163 contents: Vec<u8>,
164 filename: Option<&str>,
165 ) -> Result<Self, &'static str> {
166 let mime_type = mime_type.parse().map_err(|_| "Invalid MIME type")?;
167 Ok(Self {
168 name: name.to_owned(),
169 filename: filename.map(|f| f.to_owned()),
170 mime_type,
171 contents,
172 })
173 }
174
175 /// Serialize this part into a chunk that can be easily inserted into a larger form
176 pub(super) fn serialize(&self) -> Vec<u8> {
177 // A part is serialized in this general format:
178 // // the filename is optional
179 // Content-Disposition: form-data; name="FIELD_NAME"; filename="FILENAME"\r\n
180 // // the mime type (not strictly required by the spec, but always sent here)
181 // Content-Type: mime/type\r\n
182 // // a blank line, then the contents of the file start
183 // \r\n
184 // CONTENTS\r\n
185
186 // Format what we can as a string, then handle the rest at a byte level
187 let mut serialized_part = format!("Content-Disposition: form-data; name=\"{}\"", self.name);
188 // specify a filename if one was set
189 if let Some(filename) = &self.filename {
190 serialized_part += &format!("; filename=\"{filename}\"");
191 }
192 serialized_part += "\r\n";
193 // specify the MIME type
194 serialized_part += &format!("Content-Type: {}\r\n", self.mime_type);
195 serialized_part += "\r\n";
196 let mut part_bytes = serialized_part.as_bytes().to_vec();
197 part_bytes.extend_from_slice(&self.contents);
198 part_bytes.extend_from_slice(b"\r\n");
199
200 part_bytes
201 }
202}
203
204impl FromIterator<Part> for MultipartForm {
205 fn from_iter<T: IntoIterator<Item = Part>>(iter: T) -> Self {
206 Self {
207 parts: iter.into_iter().collect(),
208 }
209 }
210}
211
212/// A boundary is defined as a user defined (arbitrary) value that does not occur in any of the data.
213///
214/// Because the specification does not clearly define a methodology for generating boundaries, this implementation
215/// follow's Reqwest's, and generates a boundary in the format of `XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX` where `XXXXXXXX`
216/// is a hexadecimal representation of a pseudo randomly generated u64.
217fn generate_boundary() -> String {
218 let a = fastrand::u64(0..u64::MAX);
219 let b = fastrand::u64(0..u64::MAX);
220 let c = fastrand::u64(0..u64::MAX);
221 let d = fastrand::u64(0..u64::MAX);
222 format!("{a:016x}-{b:016x}-{c:016x}-{d:016x}")
223}
224
225#[cfg(test)]
226mod tests {
227 use super::{generate_boundary, MultipartForm, Part};
228 use axum::{body::Body, http};
229 use axum::{routing::get, Router};
230 use http::{Request, Response};
231 use http_body_util::BodyExt;
232 use mime::Mime;
233 use tower::ServiceExt;
234
235 #[tokio::test]
236 async fn process_form() -> Result<(), Box<dyn std::error::Error>> {
237 // create a boilerplate handle that returns a form
238 async fn handle() -> MultipartForm {
239 let parts: Vec<Part> = vec![
240 Part::text("part1".to_owned(), "basictext"),
241 Part::file(
242 "part2",
243 "file.txt",
244 vec![0x68, 0x69, 0x20, 0x6d, 0x6f, 0x6d],
245 ),
246 Part::raw_part("part3", "text/plain", b"rawpart".to_vec(), None).unwrap(),
247 ];
248 MultipartForm::from_iter(parts)
249 }
250
251 // make a request to that handle
252 let app = Router::new().route("/", get(handle));
253 let response: Response<_> = app
254 .oneshot(Request::builder().uri("/").body(Body::empty())?)
255 .await?;
256 // content_type header
257 let ct_header = response.headers().get("content-type").unwrap().to_str()?;
258 let boundary = ct_header.split("boundary=").nth(1).unwrap().to_owned();
259 let body: &[u8] = &response.into_body().collect().await?.to_bytes();
260 assert_eq!(
261 std::str::from_utf8(body)?,
262 format!(
263 "--{boundary}\r\n\
264 Content-Disposition: form-data; name=\"part1\"\r\n\
265 Content-Type: text/plain; charset=utf-8\r\n\
266 \r\n\
267 basictext\r\n\
268 --{boundary}\r\n\
269 Content-Disposition: form-data; name=\"part2\"; filename=\"file.txt\"\r\n\
270 Content-Type: application/octet-stream\r\n\
271 \r\n\
272 hi mom\r\n\
273 --{boundary}\r\n\
274 Content-Disposition: form-data; name=\"part3\"\r\n\
275 Content-Type: text/plain\r\n\
276 \r\n\
277 rawpart\r\n\
278 --{boundary}--",
279 )
280 );
281
282 Ok(())
283 }
284
285 #[test]
286 fn valid_boundary_generation() {
287 for _ in 0..256 {
288 let boundary = generate_boundary();
289 let mime_type: Result<Mime, _> =
290 format!("multipart/form-data; boundary={boundary}").parse();
291 assert!(
292 mime_type.is_ok(),
293 "The generated boundary was unable to be parsed into a valid mime type."
294 );
295 }
296 }
297}