File uploads
Accept a multipart/form-data upload with poem's Multipart extractor and stream the part into object storage.
Take a file part straight from a multipart/form-data request with
poem’s Multipart extractor. #[routes] passes it through like any other
handler argument, so a direct upload is the single-round-trip alternative
to the presigned flow: the client posts the bytes, the server
stores them.
#[post("/uploads/direct")]async fn upload_direct(&self, mut form: Multipart) -> Result<Json<PresignedUrlDto>> { while let Some(part) = form.next_field().await.map_err(bad_request)? { if part.name() != Some("file") { continue; } let filename = part.file_name().map(str::to_owned).unwrap_or_default(); UploadRequestDto { filename: filename.clone() }.validate().map_err(unprocessable)?; let ticket = self.svc.store_upload(&filename, part.into_byte_stream()) .await.map_err(internal)?; return Ok(Json(ticket)); } Err(Error::from_string("multipart body has no `file` part", StatusCode::BAD_REQUEST))}Two lines carry the weight: the filename is validated before it becomes
an object key, and into_byte_stream() hands the part to storage as it
arrives, so it never exists whole in memory.
Stream the part instead of buffering it
Section titled “Stream the part instead of buffering it”field.bytes() is the buffered form: the whole part lands in memory before the
handler sees any of it. That is right for a form value and wrong for a file.
PartExt::into_byte_stream() is the other half — the part as it arrives, in the
shape Storage::put_stream consumes:
use nest_rs::http::PartExt;
pub async fn store_upload( &self, filename: &str, part: impl Stream<Item = std::io::Result<Bytes>> + Send,) -> Result<PresignedUrlDto, AudioError> { let key = format!("{}-{filename}", Uuid::now_v7()); self.storage.put_stream(&key, AUDIO_CONTENT_TYPE, part).await?; // …}The service names a stream, not a transport type, so the same method serves a multipart part, a proxied download, or a test fixture. Peak memory is one multipart chunk plus one object-store part, whatever the file’s size.
Validate the filename at the edge
Section titled “Validate the filename at the edge”A multipart part’s filename is attacker-controlled — never let it become
an object key unchecked. Reuse the same validated DTO the rest of the
slice binds (UploadRequestDto), so one anti-traversal allowlist covers
both the presigned and the direct path. A rejected name is a 422, never
a stored object.
Document the form
Section titled “Document the form”Nothing in a Multipart handler states what the form’s parts are, so
#[api(multipart = Type)] declares them and the
OpenAPI document files the schema under multipart/form-data:
#[derive(JsonSchema)]pub struct DirectUploadDto { #[schemars(extend("format" = "binary"))] pub file: String,}
#[post("/uploads/direct")]#[api(multipart = DirectUploadDto)]async fn upload_direct(&self, mut form: Multipart) -> Result<Json<PresignedUrlDto>> { /* ... */ }format: binary is what makes Swagger UI render a file picker for the part.
Undeclared, a Multipart handler still documents multipart/form-data with a
free-form object — the media type is knowledge a client needs even when no type
states the parts.
Presigned or direct
Section titled “Presigned or direct”| Presigned PUT | Direct multipart | |
|---|---|---|
| Bytes flow | client → storage | client → server → storage |
| Server memory | none | one chunk at a time |
| Round trips | two (mint, then PUT) | one |
| Use when | large media, offload the transfer | small files, one call |
Limits
Section titled “Limits”- The
max_body_bytescap gates every body,Multipartincluded. A direct upload larger than the cap answers413 Payload Too Largebefore a byte reaches your handler, and the default cap is 2 MiB — so a 3 MiB upload fails out of the box. Raising the ceiling isNESTRS_HTTP__MAX_BODY_BYTES, not a compensating control in your code. The admin gate and rate limit the demo binds are defence in depth, not the size bound. - Streaming bounds memory, not the request: the part is still read through
the request body, so
max_body_bytesis the ceiling either way. Raising it is what lets a large file through; streaming is what keeps the process from holding it. For very large media the presigned PUT still wins — it avoids proxying the bytes through the server at all.
Going further
Section titled “Going further”- File storage — the presigned flow and the object-store client.
audioin the demo — the direct upload in full.- Streaming responses — the download counterpart.