arcly-http 0.3.1

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! `multipart/form-data` parsing for file uploads and mixed forms.
//!
//! The request body is already buffered in the [`RequestContext`] (the one
//! pipeline reads it once, subject to the `LaunchConfig` body cap), so this
//! parses that buffer rather than touching the socket again. Call it from a
//! handler that received the raw context:
//!
//! ```ignore
//! #[Post("/avatar")]
//! async fn upload(&self, ctx: &RequestContext) -> Result<String, Error> {
//!     let form = MultipartForm::from_ctx(ctx).await?;
//!     let file = form.file("avatar").ok_or(Error::BadRequest("avatar required"))?;
//!     store(file.file_name.as_deref(), &file.bytes).await;
//!     Ok(format!("received {} bytes", file.bytes.len()))
//! }
//! ```

use bytes::Bytes;

use crate::web::{Error, RequestContext};

/// One part of a parsed multipart body — a form field or an uploaded file.
#[derive(Debug, Clone)]
pub struct Part {
    /// The `name` attribute from the part's `Content-Disposition`.
    pub name: String,
    /// The `filename` attribute, present when the part is a file upload.
    pub file_name: Option<String>,
    /// The part's declared `Content-Type`, if any.
    pub content_type: Option<String>,
    /// The raw bytes of the part.
    pub bytes: Bytes,
}

impl Part {
    /// Interpret the part's bytes as UTF-8 text (for non-file form fields).
    pub fn text(&self) -> Option<&str> {
        std::str::from_utf8(&self.bytes).ok()
    }

    /// Whether this part carries a `filename` (i.e. is a file upload).
    pub fn is_file(&self) -> bool {
        self.file_name.is_some()
    }
}

/// A fully parsed `multipart/form-data` body.
#[derive(Debug, Clone, Default)]
pub struct MultipartForm {
    parts: Vec<Part>,
}

impl MultipartForm {
    /// Parse the buffered request body as `multipart/form-data`.
    ///
    /// Returns [`Error::BadRequest`] when the `Content-Type` is not multipart,
    /// the boundary is missing, or the body is malformed.
    pub async fn from_ctx(ctx: &RequestContext) -> Result<Self, Error> {
        let content_type = ctx
            .header("content-type")
            .ok_or(Error::BadRequest("missing content-type for multipart body"))?;
        Self::parse(content_type, ctx.body().clone()).await
    }

    /// Parse an explicit content-type header and body buffer. Exposed for
    /// testing and for callers that already hold the pieces.
    pub async fn parse(content_type: &str, body: Bytes) -> Result<Self, Error> {
        let boundary = multer::parse_boundary(content_type)
            .map_err(|_| Error::BadRequest("not a multipart/form-data body"))?;

        let stream =
            futures::stream::once(async move { Ok::<Bytes, std::convert::Infallible>(body) });
        let mut multipart = multer::Multipart::new(stream, boundary);

        let mut parts = Vec::new();
        while let Some(field) = multipart
            .next_field()
            .await
            .map_err(|_| Error::BadRequest("malformed multipart body"))?
        {
            let name = field.name().unwrap_or("").to_string();
            let file_name = field.file_name().map(str::to_string);
            let content_type = field.content_type().map(|m| m.to_string());
            let bytes = field
                .bytes()
                .await
                .map_err(|_| Error::BadRequest("failed to read multipart field"))?;
            parts.push(Part {
                name,
                file_name,
                content_type,
                bytes,
            });
        }
        Ok(Self { parts })
    }

    /// All parsed parts in wire order.
    pub fn parts(&self) -> &[Part] {
        &self.parts
    }

    /// The first part with the given field name, if any.
    pub fn part(&self, name: &str) -> Option<&Part> {
        self.parts.iter().find(|p| p.name == name)
    }

    /// The text value of the first non-file field with `name`.
    pub fn text(&self, name: &str) -> Option<&str> {
        self.part(name)
            .filter(|p| !p.is_file())
            .and_then(Part::text)
    }

    /// The first file part with `name`.
    pub fn file(&self, name: &str) -> Option<&Part> {
        self.parts.iter().find(|p| p.name == name && p.is_file())
    }

    /// Every file part, in wire order.
    pub fn files(&self) -> impl Iterator<Item = &Part> {
        self.parts.iter().filter(|p| p.is_file())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> (String, Bytes) {
        let b = "X-BOUNDARY";
        let body = format!(
            "--{b}\r\n\
             Content-Disposition: form-data; name=\"title\"\r\n\r\n\
             Hello World\r\n\
             --{b}\r\n\
             Content-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\
             Content-Type: image/png\r\n\r\n\
             PNGDATA\r\n\
             --{b}--\r\n"
        );
        (
            format!("multipart/form-data; boundary={b}"),
            Bytes::from(body),
        )
    }

    #[tokio::test]
    async fn parses_fields_and_files() {
        let (ct, body) = sample();
        let form = MultipartForm::parse(&ct, body).await.unwrap();
        assert_eq!(form.parts().len(), 2);
        assert_eq!(form.text("title"), Some("Hello World"));

        let file = form.file("avatar").unwrap();
        assert_eq!(file.file_name.as_deref(), Some("a.png"));
        assert_eq!(file.content_type.as_deref(), Some("image/png"));
        assert_eq!(&file.bytes[..], b"PNGDATA");
        assert_eq!(form.files().count(), 1);
    }

    #[tokio::test]
    async fn rejects_non_multipart() {
        let err = MultipartForm::parse("application/json", Bytes::from_static(b"{}"))
            .await
            .unwrap_err();
        assert!(matches!(err, Error::BadRequest(_)));
    }
}