|
| 1 | +//! Generate forms to use in responses. |
| 2 | +
|
| 3 | +use axum::response::{IntoResponse, Response}; |
| 4 | +use fastrand; |
| 5 | +use http::{header, HeaderMap, StatusCode}; |
| 6 | +use 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 | +#[derive(Debug)] |
| 12 | +pub struct MultipartForm { |
| 13 | + parts: Vec<Part>, |
| 14 | +} |
| 15 | + |
| 16 | +impl MultipartForm { |
| 17 | + /// Initialize a new multipart form with the provided vector of parts. |
| 18 | + /// |
| 19 | + /// # Examples |
| 20 | + /// |
| 21 | + /// ```rust |
| 22 | + /// use axum_extra::response::multiple::{MultipartForm, Part}; |
| 23 | + /// |
| 24 | + /// let parts: Vec<Part> = vec![Part::text("foo".to_string(), "abc"), Part::text("bar".to_string(), "def")]; |
| 25 | + /// let form = MultipartForm::with_parts(parts); |
| 26 | + /// ``` |
| 27 | + #[deprecated] |
| 28 | + pub fn with_parts(parts: Vec<Part>) -> Self { |
| 29 | + MultipartForm { parts } |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +impl 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!("--{}\r\n", boundary).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)] |
| 68 | +pub 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 | + |
| 92 | +impl 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 | + pub fn text(name: String, contents: &str) -> Self { |
| 108 | + Self { |
| 109 | + name, |
| 110 | + filename: None, |
| 111 | + mime_type: mime::TEXT_PLAIN_UTF_8, |
| 112 | + contents: contents.as_bytes().to_vec(), |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + /// Create a new part containing a generic file, with a `Content-Type` of `application/octet-stream` |
| 117 | + /// using the provided file name, field name, and contents. |
| 118 | + /// |
| 119 | + /// If the MIME type of the file is known, consider using `Part::raw_part`. |
| 120 | + /// |
| 121 | + /// # Examples |
| 122 | + /// |
| 123 | + /// ```rust |
| 124 | + /// use axum_extra::response::multiple::{MultipartForm, Part}; |
| 125 | + /// |
| 126 | + /// // create a form with a single part that has a field with a name of "foo", |
| 127 | + /// // with a file name of "foo.txt", and with the specified contents |
| 128 | + /// let parts: Vec<Part> = vec![Part::file("foo", "foo.txt", vec![0x68, 0x68, 0x20, 0x6d, 0x6f, 0x6d])]; |
| 129 | + /// let form = MultipartForm::from_iter(parts); |
| 130 | + /// ``` |
| 131 | + pub fn file(field_name: &str, file_name: &str, contents: Vec<u8>) -> Self { |
| 132 | + Self { |
| 133 | + name: field_name.to_owned(), |
| 134 | + filename: Some(file_name.to_owned()), |
| 135 | + // If the `MIME` type is not known or specified, then the MIME type should be set to `application/octet-stream`. |
| 136 | + // See RFC2388 section 3 for specifics. |
| 137 | + mime_type: mime::APPLICATION_OCTET_STREAM, |
| 138 | + contents, |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | + /// Create a new part with more fine-grained control over the semantics of that part. |
| 143 | + /// |
| 144 | + /// The caller is assumed to have set a valid MIME type. |
| 145 | + /// |
| 146 | + /// This function will return an error if the provided MIME type is not valid. |
| 147 | + /// |
| 148 | + /// # Examples |
| 149 | + /// |
| 150 | + /// ```rust |
| 151 | + /// use axum_extra::response::multiple::{MultipartForm, Part}; |
| 152 | + /// |
| 153 | + /// // create a form with a single part that has a field with a name of "part_name", |
| 154 | + /// // with a MIME type of "application/json", and the supplied contents. |
| 155 | + /// 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")]; |
| 156 | + /// let form = MultipartForm::from_iter(parts); |
| 157 | + /// ``` |
| 158 | + pub fn raw_part( |
| 159 | + name: &str, |
| 160 | + mime_type: &str, |
| 161 | + contents: Vec<u8>, |
| 162 | + filename: Option<&str>, |
| 163 | + ) -> Result<Self, &'static str> { |
| 164 | + let mime_type = mime_type.parse().map_err(|_| "Invalid MIME type")?; |
| 165 | + Ok(Self { |
| 166 | + name: name.to_owned(), |
| 167 | + filename: filename.map(|f| f.to_owned()), |
| 168 | + mime_type, |
| 169 | + contents, |
| 170 | + }) |
| 171 | + } |
| 172 | + |
| 173 | + /// Serialize this part into a chunk that can be easily inserted into a larger form |
| 174 | + pub(super) fn serialize(&self) -> Vec<u8> { |
| 175 | + // A part is serialized in this general format: |
| 176 | + // // the filename is optional |
| 177 | + // Content-Disposition: form-data; name="FIELD_NAME"; filename="FILENAME"\r\n |
| 178 | + // // the mime type (not strictly required by the spec, but always sent here) |
| 179 | + // Content-Type: mime/type\r\n |
| 180 | + // // a blank line, then the contents of the file start |
| 181 | + // \r\n |
| 182 | + // CONTENTS\r\n |
| 183 | + |
| 184 | + // Format what we can as a string, then handle the rest at a byte level |
| 185 | + let mut serialized_part = format!("Content-Disposition: form-data; name=\"{}\"", self.name); |
| 186 | + // specify a filename if one was set |
| 187 | + if let Some(filename) = &self.filename { |
| 188 | + serialized_part += &format!("; filename=\"{}\"", filename); |
| 189 | + } |
| 190 | + serialized_part += "\r\n"; |
| 191 | + // specify the MIME type |
| 192 | + serialized_part += &format!("Content-Type: {}\r\n", self.mime_type); |
| 193 | + serialized_part += "\r\n"; |
| 194 | + let mut part_bytes = serialized_part.as_bytes().to_vec(); |
| 195 | + part_bytes.extend_from_slice(&self.contents); |
| 196 | + part_bytes.extend_from_slice(b"\r\n"); |
| 197 | + |
| 198 | + part_bytes |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +impl FromIterator<Part> for MultipartForm { |
| 203 | + fn from_iter<T: IntoIterator<Item = Part>>(iter: T) -> Self { |
| 204 | + Self { |
| 205 | + parts: iter.into_iter().collect(), |
| 206 | + } |
| 207 | + } |
| 208 | +} |
| 209 | + |
| 210 | +/// A boundary is defined as a user defined (arbitrary) value that does not occur in any of the data. |
| 211 | +/// |
| 212 | +/// Because the specification does not clearly define a methodology for generating boundaries, this implementation |
| 213 | +/// follow's Reqwest's, and generates a boundary in the format of `XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX` where `XXXXXXXX` |
| 214 | +/// is a hexadecimal representation of a pseudo randomly generated u64. |
| 215 | +fn generate_boundary() -> String { |
| 216 | + let a = fastrand::u64(0..u64::MAX); |
| 217 | + let b = fastrand::u64(0..u64::MAX); |
| 218 | + let c = fastrand::u64(0..u64::MAX); |
| 219 | + let d = fastrand::u64(0..u64::MAX); |
| 220 | + format!("{a:016x}-{b:016x}-{c:016x}-{d:016x}") |
| 221 | +} |
| 222 | + |
| 223 | +#[cfg(test)] |
| 224 | +mod tests { |
| 225 | + use super::{generate_boundary, MultipartForm, Part}; |
| 226 | + use axum::{body::Body, http}; |
| 227 | + use axum::{routing::get, Router}; |
| 228 | + use http::{Request, Response}; |
| 229 | + use http_body_util::BodyExt; |
| 230 | + use mime::Mime; |
| 231 | + use tower::ServiceExt; |
| 232 | + |
| 233 | + #[tokio::test] |
| 234 | + async fn process_form() -> Result<(), Box<dyn std::error::Error>> { |
| 235 | + // create a boilerplate handle that returns a form |
| 236 | + async fn handle() -> MultipartForm { |
| 237 | + let parts: Vec<Part> = vec![ |
| 238 | + Part::text("part1".to_owned(), "basictext"), |
| 239 | + Part::file( |
| 240 | + "part2", |
| 241 | + "file.txt", |
| 242 | + vec![0x68, 0x69, 0x20, 0x6d, 0x6f, 0x6d], |
| 243 | + ), |
| 244 | + Part::raw_part("part3", "text/plain", b"rawpart".to_vec(), None).unwrap(), |
| 245 | + ]; |
| 246 | + MultipartForm::from_iter(parts) |
| 247 | + } |
| 248 | + |
| 249 | + // make a request to that handle |
| 250 | + let app = Router::new().route("/", get(handle)); |
| 251 | + let response: Response<_> = app |
| 252 | + .oneshot(Request::builder().uri("/").body(Body::empty())?) |
| 253 | + .await?; |
| 254 | + // content_type header |
| 255 | + let ct_header = response.headers().get("content-type").unwrap().to_str()?; |
| 256 | + let boundary = ct_header.split("boundary=").nth(1).unwrap().to_owned(); |
| 257 | + let body: &[u8] = &response.into_body().collect().await?.to_bytes(); |
| 258 | + assert_eq!( |
| 259 | + std::str::from_utf8(body)?, |
| 260 | + &format!( |
| 261 | + "--{boundary}\r\n\ |
| 262 | + Content-Disposition: form-data; name=\"part1\"\r\n\ |
| 263 | + Content-Type: text/plain; charset=utf-8\r\n\ |
| 264 | + \r\n\ |
| 265 | + basictext\r\n\ |
| 266 | + --{boundary}\r\n\ |
| 267 | + Content-Disposition: form-data; name=\"part2\"; filename=\"file.txt\"\r\n\ |
| 268 | + Content-Type: application/octet-stream\r\n\ |
| 269 | + \r\n\ |
| 270 | + hi mom\r\n\ |
| 271 | + --{boundary}\r\n\ |
| 272 | + Content-Disposition: form-data; name=\"part3\"\r\n\ |
| 273 | + Content-Type: text/plain\r\n\ |
| 274 | + \r\n\ |
| 275 | + rawpart\r\n\ |
| 276 | + --{boundary}--", |
| 277 | + boundary = boundary |
| 278 | + ) |
| 279 | + ); |
| 280 | + |
| 281 | + Ok(()) |
| 282 | + } |
| 283 | + |
| 284 | + #[test] |
| 285 | + fn valid_boundary_generation() { |
| 286 | + for _ in 0..256 { |
| 287 | + let boundary = generate_boundary(); |
| 288 | + let mime_type: Result<Mime, _> = |
| 289 | + format!("multipart/form-data; boundary={}", boundary).parse(); |
| 290 | + assert!( |
| 291 | + mime_type.is_ok(), |
| 292 | + "The generated boundary was unable to be parsed into a valid mime type." |
| 293 | + ); |
| 294 | + } |
| 295 | + } |
| 296 | +} |
0 commit comments