arcly_http/web/
multipart.rs1use bytes::Bytes;
19
20use crate::web::{Error, RequestContext};
21
22#[derive(Debug, Clone)]
24pub struct Part {
25 pub name: String,
27 pub file_name: Option<String>,
29 pub content_type: Option<String>,
31 pub bytes: Bytes,
33}
34
35impl Part {
36 pub fn text(&self) -> Option<&str> {
38 std::str::from_utf8(&self.bytes).ok()
39 }
40
41 pub fn is_file(&self) -> bool {
43 self.file_name.is_some()
44 }
45}
46
47#[derive(Debug, Clone, Default)]
49pub struct MultipartForm {
50 parts: Vec<Part>,
51}
52
53impl MultipartForm {
54 pub async fn from_ctx(ctx: &RequestContext) -> Result<Self, Error> {
59 let content_type = ctx
60 .header("content-type")
61 .ok_or(Error::BadRequest("missing content-type for multipart body"))?;
62 Self::parse(content_type, ctx.body().clone()).await
63 }
64
65 pub async fn parse(content_type: &str, body: Bytes) -> Result<Self, Error> {
68 let boundary = multer::parse_boundary(content_type)
69 .map_err(|_| Error::BadRequest("not a multipart/form-data body"))?;
70
71 let stream =
72 futures::stream::once(async move { Ok::<Bytes, std::convert::Infallible>(body) });
73 let mut multipart = multer::Multipart::new(stream, boundary);
74
75 let mut parts = Vec::new();
76 while let Some(field) = multipart
77 .next_field()
78 .await
79 .map_err(|_| Error::BadRequest("malformed multipart body"))?
80 {
81 let name = field.name().unwrap_or("").to_string();
82 let file_name = field.file_name().map(str::to_string);
83 let content_type = field.content_type().map(|m| m.to_string());
84 let bytes = field
85 .bytes()
86 .await
87 .map_err(|_| Error::BadRequest("failed to read multipart field"))?;
88 parts.push(Part {
89 name,
90 file_name,
91 content_type,
92 bytes,
93 });
94 }
95 Ok(Self { parts })
96 }
97
98 pub fn parts(&self) -> &[Part] {
100 &self.parts
101 }
102
103 pub fn part(&self, name: &str) -> Option<&Part> {
105 self.parts.iter().find(|p| p.name == name)
106 }
107
108 pub fn text(&self, name: &str) -> Option<&str> {
110 self.part(name)
111 .filter(|p| !p.is_file())
112 .and_then(Part::text)
113 }
114
115 pub fn file(&self, name: &str) -> Option<&Part> {
117 self.parts.iter().find(|p| p.name == name && p.is_file())
118 }
119
120 pub fn files(&self) -> impl Iterator<Item = &Part> {
122 self.parts.iter().filter(|p| p.is_file())
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 fn sample() -> (String, Bytes) {
131 let b = "X-BOUNDARY";
132 let body = format!(
133 "--{b}\r\n\
134 Content-Disposition: form-data; name=\"title\"\r\n\r\n\
135 Hello World\r\n\
136 --{b}\r\n\
137 Content-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\
138 Content-Type: image/png\r\n\r\n\
139 PNGDATA\r\n\
140 --{b}--\r\n"
141 );
142 (
143 format!("multipart/form-data; boundary={b}"),
144 Bytes::from(body),
145 )
146 }
147
148 #[tokio::test]
149 async fn parses_fields_and_files() {
150 let (ct, body) = sample();
151 let form = MultipartForm::parse(&ct, body).await.unwrap();
152 assert_eq!(form.parts().len(), 2);
153 assert_eq!(form.text("title"), Some("Hello World"));
154
155 let file = form.file("avatar").unwrap();
156 assert_eq!(file.file_name.as_deref(), Some("a.png"));
157 assert_eq!(file.content_type.as_deref(), Some("image/png"));
158 assert_eq!(&file.bytes[..], b"PNGDATA");
159 assert_eq!(form.files().count(), 1);
160 }
161
162 #[tokio::test]
163 async fn rejects_non_multipart() {
164 let err = MultipartForm::parse("application/json", Bytes::from_static(b"{}"))
165 .await
166 .unwrap_err();
167 assert!(matches!(err, Error::BadRequest(_)));
168 }
169}