Skip to main content

arcly_http/web/
multipart.rs

1//! `multipart/form-data` parsing for file uploads and mixed forms.
2//!
3//! The request body is already buffered in the [`RequestContext`] (the one
4//! pipeline reads it once, subject to the `LaunchConfig` body cap), so this
5//! parses that buffer rather than touching the socket again. Call it from a
6//! handler that received the raw context:
7//!
8//! ```ignore
9//! #[Post("/avatar")]
10//! async fn upload(&self, ctx: &RequestContext) -> Result<String, Error> {
11//!     let form = MultipartForm::from_ctx(ctx).await?;
12//!     let file = form.file("avatar").ok_or(Error::BadRequest("avatar required"))?;
13//!     store(file.file_name.as_deref(), &file.bytes).await;
14//!     Ok(format!("received {} bytes", file.bytes.len()))
15//! }
16//! ```
17
18use bytes::Bytes;
19
20use crate::web::{Error, RequestContext};
21
22/// One part of a parsed multipart body — a form field or an uploaded file.
23#[derive(Debug, Clone)]
24pub struct Part {
25    /// The `name` attribute from the part's `Content-Disposition`.
26    pub name: String,
27    /// The `filename` attribute, present when the part is a file upload.
28    pub file_name: Option<String>,
29    /// The part's declared `Content-Type`, if any.
30    pub content_type: Option<String>,
31    /// The raw bytes of the part.
32    pub bytes: Bytes,
33}
34
35impl Part {
36    /// Interpret the part's bytes as UTF-8 text (for non-file form fields).
37    pub fn text(&self) -> Option<&str> {
38        std::str::from_utf8(&self.bytes).ok()
39    }
40
41    /// Whether this part carries a `filename` (i.e. is a file upload).
42    pub fn is_file(&self) -> bool {
43        self.file_name.is_some()
44    }
45}
46
47/// A fully parsed `multipart/form-data` body.
48#[derive(Debug, Clone, Default)]
49pub struct MultipartForm {
50    parts: Vec<Part>,
51}
52
53impl MultipartForm {
54    /// Parse the buffered request body as `multipart/form-data`.
55    ///
56    /// Returns [`Error::BadRequest`] when the `Content-Type` is not multipart,
57    /// the boundary is missing, or the body is malformed.
58    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    /// Parse an explicit content-type header and body buffer. Exposed for
66    /// testing and for callers that already hold the pieces.
67    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    /// All parsed parts in wire order.
99    pub fn parts(&self) -> &[Part] {
100        &self.parts
101    }
102
103    /// The first part with the given field name, if any.
104    pub fn part(&self, name: &str) -> Option<&Part> {
105        self.parts.iter().find(|p| p.name == name)
106    }
107
108    /// The text value of the first non-file field with `name`.
109    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    /// The first file part with `name`.
116    pub fn file(&self, name: &str) -> Option<&Part> {
117        self.parts.iter().find(|p| p.name == name && p.is_file())
118    }
119
120    /// Every file part, in wire order.
121    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}