Skip to content

Commit 29fa5e3

Browse files
Multipart file upload extractor (rapina-rs#363)
- rapina/src/extract/multipart.rs: Implemented [Multipart] extractor based on `multer` for efficient streaming of multipart form-data. - rapina-macros/src/lib.rs: Fixed a bug where the `mut` keyword was stripped from handler arguments, enabling the use of mutable extractors. - rapina/src/extract/multipart.rs: Added comprehensive documentation and usage examples for [Multipart] - rapina/Cargo.toml: Configured to specify `required-features` for the multipart example. - rapina/examples/multipart.rs: Enhanced example to demonstrate reading fields and file data. Co-authored-by: Antonio Souza <arfs.antonio@gmail.com>
1 parent f48fb9a commit 29fa5e3

File tree

7 files changed

+435
-1
lines changed

7 files changed

+435
-1
lines changed

Cargo.lock

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rapina/Cargo.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ dashmap = "6.1.0"
6161
smallvec = "1"
6262

6363
# Compression (optional)
64-
flate2 = { version = "1.1", optional = true }
64+
flate2 = { version = "1.1", optional = true }
65+
66+
# Multipart (optional)
67+
multer = { version = "3.0", optional = true }
6568

6669
# Our macros
6770
rapina-macros = { version = "0.10.0", path = "../rapina-macros/" }
@@ -102,6 +105,10 @@ harness = false
102105
[target.'cfg(windows)'.dev-dependencies]
103106
windows-sys = { version = "0.61.2", features = ["Win32_System_Console"] }
104107

108+
[[example]]
109+
name = "multipart"
110+
required-features = ["multipart"]
111+
105112
[features]
106113
default = ["compression"]
107114
compression = ["flate2"]
@@ -111,4 +118,5 @@ mysql = ["database", "sea-orm/sqlx-mysql", "sea-orm-migration/sqlx-mysql"]
111118
sqlite = ["database", "sea-orm/sqlx-sqlite", "sea-orm-migration/sqlx-sqlite"]
112119
metrics = ["prometheus"]
113120
cache-redis = ["redis"]
121+
multipart = ["multer", "futures-util"]
114122
websocket = ["hyper-tungstenite", "tokio-tungstenite", "futures-util"]

rapina/examples/multipart.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#![cfg(feature = "multipart")]
2+
use rapina::prelude::*;
3+
4+
/// Handler for multipart form data uploads.
5+
///
6+
/// This example demonstrates how to use the `Multipart` extractor to handle
7+
/// file uploads and form fields in a single request.
8+
#[post("/upload")]
9+
async fn upload(mut form: Multipart) -> Result<String> {
10+
let mut result = String::new();
11+
while let Some(field) = form.next_field().await? {
12+
let name = field.name().unwrap_or("unknown").to_string();
13+
let data = field.bytes().await?;
14+
15+
result.push_str(&format!(
16+
"Field: {}, Data: {}",
17+
name,
18+
String::from_utf8_lossy(&data)
19+
));
20+
}
21+
22+
Ok(result)
23+
}
24+
25+
#[tokio::main]
26+
async fn main() -> std::io::Result<()> {
27+
let router = Router::new().post("/upload", upload);
28+
29+
println!("Multipart Example Server running at http://127.0.0.1:3000");
30+
println!("Try uploading a file with curl:");
31+
println!(" curl -X POST http://127.0.0.1:3000/upload \\");
32+
println!(" -F \"title=My File\" \\");
33+
println!(" -F \"file=@Cargo.toml\"");
34+
35+
Rapina::new()
36+
.router(router)
37+
.listen("127.0.0.1:3000")
38+
.await?;
39+
40+
Ok(())
41+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ use std::ops::Deref;
1616
use std::sync::Arc;
1717
use validator::Validate;
1818

19+
#[cfg(feature = "multipart")]
20+
pub mod multipart;
21+
#[cfg(feature = "multipart")]
22+
pub use multipart::{Field, Multipart};
23+
1924
use crate::context::RequestContext;
2025
use crate::error::Error;
2126
use crate::response::{APPLICATION_JSON, BoxBody, FORM_CONTENT_TYPE, IntoResponse};

rapina/src/extract/multipart.rs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
use crate::error::Error;
2+
use crate::extract::{FromRequest, PathParams};
3+
use crate::state::AppState;
4+
use futures_util::stream::StreamExt;
5+
use http::header::CONTENT_TYPE;
6+
use hyper::body::Incoming;
7+
use std::io;
8+
use std::sync::Arc;
9+
10+
/// Extractor for multipart form data.
11+
///
12+
/// This extractor provides access to the individual fields of a multipart request.
13+
/// It uses `multer` under the hood for efficient streaming.
14+
///
15+
/// # Examples
16+
///
17+
/// ```rust,ignore
18+
/// use rapina::prelude::*;
19+
///
20+
/// #[post("/upload")]
21+
/// async fn upload(mut multipart: Multipart) -> Result<String> {
22+
/// while let Some(mut field) = multipart.next_field().await? {
23+
/// let name = field.name().unwrap_or("unknown").to_string();
24+
/// let file_name = field.file_name().map(|s| s.to_string());
25+
///
26+
/// if let Some(file_name) = file_name {
27+
/// println!("Uploading file: {} as field: {}", file_name, name);
28+
/// let data = field.bytes().await?;
29+
/// // Process file data...
30+
/// } else {
31+
/// let text = field.text().await?;
32+
/// println!("Field: {} = {}", name, text);
33+
/// }
34+
/// }
35+
/// Ok("Upload successful".to_string())
36+
/// }
37+
/// ```
38+
pub struct Multipart {
39+
inner: multer::Multipart<'static>,
40+
}
41+
42+
impl FromRequest for Multipart {
43+
async fn from_request(
44+
req: http::Request<Incoming>,
45+
_params: &PathParams,
46+
_state: &Arc<AppState>,
47+
) -> Result<Self, Error> {
48+
let boundary = req
49+
.headers()
50+
.get(CONTENT_TYPE)
51+
.and_then(|v| v.to_str().ok())
52+
.and_then(|v| multer::parse_boundary(v).ok())
53+
.ok_or_else(|| Error::bad_request("invalid or missing multipart boundary"))?;
54+
55+
let stream =
56+
http_body_util::BodyStream::new(req.into_body()).filter_map(|result| async move {
57+
match result {
58+
Ok(frame) => frame.into_data().ok().map(Ok::<_, multer::Error>),
59+
Err(e) => Some(Err(multer::Error::StreamReadFailed(Box::new(
60+
io::Error::other(e),
61+
)))),
62+
}
63+
});
64+
65+
Ok(Self::new_with_stream(stream, boundary))
66+
}
67+
}
68+
69+
impl Multipart {
70+
/// Creates a new `Multipart` instance from a stream and boundary.
71+
pub(crate) fn new_with_stream<S>(stream: S, boundary: impl Into<String>) -> Self
72+
where
73+
S: futures_util::Stream<Item = Result<bytes::Bytes, multer::Error>> + Send + 'static,
74+
{
75+
let multipart = multer::Multipart::new(stream, boundary);
76+
Multipart { inner: multipart }
77+
}
78+
79+
/// Yields the next field from the multipart body.
80+
///
81+
/// Returns `Ok(Some(field))` if a field is available, `Ok(None)` if the end of
82+
/// the stream is reached, or an error if the request is malformed.
83+
pub async fn next_field(&mut self) -> Result<Option<Field<'static>>, Error> {
84+
match self.inner.next_field().await {
85+
Ok(Some(inner)) => Ok(Some(Field { inner })),
86+
Ok(None) => Ok(None),
87+
Err(e) => Err(Error::bad_request(format!("multipart error: {}", e))),
88+
}
89+
}
90+
}
91+
92+
/// A single field in a multipart body.
93+
///
94+
/// Provides methods to access field metadata and stream its contents.
95+
pub struct Field<'a> {
96+
inner: multer::Field<'a>,
97+
}
98+
99+
impl<'a> Field<'a> {
100+
/// Returns the name of the field from the `Content-Disposition` header.
101+
pub fn name(&self) -> Option<&str> {
102+
self.inner.name()
103+
}
104+
105+
/// Returns the filename of the field from the `Content-Disposition` header.
106+
pub fn file_name(&self) -> Option<&str> {
107+
self.inner.file_name()
108+
}
109+
110+
/// Returns the content type of the field from the `Content-Type` header.
111+
pub fn content_type(&self) -> Option<&str> {
112+
self.inner.content_type().map(|c| c.as_ref())
113+
}
114+
115+
/// Reads the next chunk of bytes from the field.
116+
///
117+
/// Useful for streaming large files without loading the entire content into memory.
118+
pub async fn chunk(&mut self) -> Result<Option<bytes::Bytes>, Error> {
119+
self.inner
120+
.chunk()
121+
.await
122+
.map_err(|e| Error::bad_request(format!("multipart field error: {}", e)))
123+
}
124+
125+
/// Collects the remaining bytes from the field into a `Bytes`.
126+
pub async fn bytes(self) -> Result<bytes::Bytes, Error> {
127+
self.inner
128+
.bytes()
129+
.await
130+
.map_err(|e| Error::bad_request(format!("multipart field error: {}", e)))
131+
}
132+
133+
/// Collects the remaining bytes from the field into a `String`.
134+
pub async fn text(self) -> Result<String, Error> {
135+
self.inner
136+
.text()
137+
.await
138+
.map_err(|e| Error::bad_request(format!("multipart field error: {}", e)))
139+
}
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
use bytes::Bytes;
146+
use futures_util::stream;
147+
148+
#[tokio::test]
149+
async fn test_multipart_extraction() {
150+
let boundary = "boundary";
151+
let body = format!(
152+
"--{boundary}\r\n\
153+
Content-Disposition: form-data; name=\"foo\"\r\n\
154+
\r\n\
155+
bar\r\n\
156+
--{boundary}--\r\n"
157+
);
158+
159+
let stream = stream::once(async move { Ok::<_, multer::Error>(Bytes::from(body)) });
160+
let mut multipart = Multipart::new_with_stream(stream, boundary);
161+
162+
let field = multipart.next_field().await.unwrap().unwrap();
163+
assert_eq!(field.name(), Some("foo"));
164+
assert_eq!(field.text().await.unwrap(), "bar");
165+
166+
assert!(multipart.next_field().await.unwrap().is_none());
167+
}
168+
169+
#[tokio::test]
170+
async fn test_multipart_multiple_fields() {
171+
let boundary = "boundary";
172+
let body = format!(
173+
"--{boundary}\r\n\
174+
Content-Disposition: form-data; name=\"foo\"\r\n\
175+
\r\n\
176+
bar\r\n\
177+
--{boundary}\r\n\
178+
Content-Disposition: form-data; name=\"baz\"; filename=\"test.txt\"\r\n\
179+
Content-Type: text/plain\r\n\
180+
\r\n\
181+
qux\r\n\
182+
--{boundary}--\r\n"
183+
);
184+
185+
let stream = stream::once(async move { Ok::<_, multer::Error>(Bytes::from(body)) });
186+
let mut multipart = Multipart::new_with_stream(stream, boundary);
187+
188+
let field1 = multipart.next_field().await.unwrap().unwrap();
189+
assert_eq!(field1.name(), Some("foo"));
190+
assert_eq!(field1.text().await.unwrap(), "bar");
191+
192+
let field2 = multipart.next_field().await.unwrap().unwrap();
193+
assert_eq!(field2.name(), Some("baz"));
194+
assert_eq!(field2.file_name(), Some("test.txt"));
195+
assert_eq!(field2.content_type(), Some("text/plain"));
196+
assert_eq!(field2.text().await.unwrap(), "qux");
197+
198+
assert!(multipart.next_field().await.unwrap().is_none());
199+
}
200+
}

rapina/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
//! - [`State`](extract::State) - Access application state
5757
//! - [`Context`](extract::Context) - Access request context with trace_id
5858
//! - [`Validated`](extract::Validated) - Validate extracted data
59+
//! - [`Multipart`](extract::Multipart) - Parse multipart form data (e.g. file uploads)
60+
5961
//!
6062
//! ## Middleware
6163
//!
@@ -131,6 +133,8 @@ pub mod prelude {
131133
pub use crate::context::RequestContext;
132134
pub use crate::error::{DocumentedError, Error, ErrorVariant, IntoApiError, Result};
133135
pub use crate::extract::{Context, Cookie, Form, Headers, Json, Path, Query, State, Validated};
136+
#[cfg(feature = "multipart")]
137+
pub use crate::extract::{Field, Multipart};
134138
pub use crate::introspection::RouteInfo;
135139
pub use crate::middleware::{
136140
KeyExtractor, Middleware, Next, RateLimitConfig, RequestLogConfig,

0 commit comments

Comments
 (0)