Skip to content

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.

crates/features/src/audio/http/controller.rs (from the demo, abridged)
#[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.

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:

crates/features/src/audio/service.rs (from the demo, abridged)
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.

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.

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:

crates/features/src/media/http/controller.rs
#[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 PUTDirect multipart
Bytes flowclient → storageclient → server → storage
Server memorynoneone chunk at a time
Round tripstwo (mint, then PUT)one
Use whenlarge media, offload the transfersmall files, one call
  • The max_body_bytes cap gates every body, Multipart included. A direct upload larger than the cap answers 413 Payload Too Large before 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 is NESTRS_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_bytes is 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.