Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Multipart Uploads

Caelix accepts file uploads alongside typed request DTOs on both supported runtimes. A controller method keeps its normal #[body] DTO and declares file parts independently with #[file] or #[files]. JSON clients and multipart clients can therefore use the same route whenever its file arguments are optional.

Typed fields and one required file

Use #[body] for the non-file fields and #[file] for one upload. Multipart text fields use typed Serde form decoding: strings, numbers, booleans, optional fields, and repeated collection fields are decoded into the DTO before the controller runs.

#![allow(unused)]
fn main() {
use caelix::{Response, Result, UploadedFile, controller, injectable};
use serde::Deserialize;
use serde_json::{Value, json};
use validator::Validate;

#[derive(Deserialize, Validate)]
struct DocumentInput {
    #[validate(length(min = 3))]
    title: String,
    #[validate(range(min = 1))]
    quantity: u32,
    published: bool,
    #[serde(default)]
    labels: Vec<String>,
}

#[injectable]
struct DocumentsController;

#[controller("/documents")]
impl DocumentsController {
    #[post("")]
    async fn create(
        &self,
        #[body] #[validate] input: DocumentInput,
        #[file] document: UploadedFile,
    ) -> Result<Response<Value>> {
        let contents = document.read_bytes().await?;
        Ok(Response::Body(json!({
            "title": input.title,
            "bytes": contents.len(),
        })))
    }
}
}

The file-part name defaults to the Rust parameter name. Rename it when the HTTP contract needs a different name:

#![allow(unused)]
fn main() {
#[file(name = "cover_image")] image: UploadedFile
}

File validation

#[file(...)] and #[files(...)] can validate each staged file before the handler runs. max_size accepts a whole number with B, KB, MB, GB, KiB, MiB, or GiB; decimal suffixes use powers of 1,000 and IEC suffixes use powers of 1,024. content_type is a comma-separated MIME allowlist.

#![allow(unused)]
fn main() {
#[post("/documents")]
async fn create(
    &self,
    #[file(
        name = "document",
        max_size = "10MiB",
        content_type = "application/pdf, image/png",
        validate = validate_document,
    )]
    document: UploadedFile,
) -> Result<Response<Document>> {
    Ok(Response::Body(self.documents.store(document).await?))
}
}

Caelix checks, in order: declared size, declared content type, then the controller validator. Size violations return 413 Payload Too Large; MIME violations and undetectable files return 400 Bad Request. MIME validation is secure by default: Caelix detects the type from the file’s magic bytes and does not trust a client filename or part header.

Set trust_content_type_header = true only when that header is part of your explicit trust boundary. It must accompany content_type; header parameters such as charset=utf-8 are ignored. This mode accepts the client declaration instead of inspecting file bytes, so it is unsafe for untrusted clients.

Validators must be controller methods with this exact shape:

#![allow(unused)]
fn main() {
async fn validate_document(&self, file: &UploadedFile) -> Result<()> {
    self.document_rules.validate(file).await
}
}

For reusable rules, inject a service into the controller and forward to it as above. A validator receives only its attached file; keep cross-field checks in the handler body after every extractor is available. Optional files skip all file checks when absent. Repeated files are checked in request order. If any check fails, Caelix drops the collected files and all remaining staged files, deleting their temporary paths before returning the error response.

#[validate] runs after JSON or multipart DTO decoding. A successful multipart decode that fails validation returns Caelix’s normal 400 Bad Request validation envelope, including its errors map.

Content negotiation

#[body] accepts the following content types:

Route argumentsAccepted request content typesFile argument result
DTO onlyapplication/json, omitted content type, multipart/form-dataNot applicable
DTO + required UploadedFilemultipart/form-dataRequired file must occur exactly once
DTO + Option<UploadedFile>JSON, omitted content type, multipart/form-dataJSON supplies None; multipart may supply one file
DTO + Vec<UploadedFile>multipart/form-dataRepeated file field, or an empty vector when absent
#[multipart] MultipartFormmultipart/form-dataFull form access

Other declared content types return 415 Unsupported Media Type. Invalid boundaries, malformed parts, duplicate values for a single-file extractor, and invalid typed form values return 400 Bad Request.

Optional and repeated files

Optional files allow the endpoint to retain a JSON workflow. They are None for JSON requests and when the named multipart part is not present:

#![allow(unused)]
fn main() {
#[post("/profile")]
async fn update(
    &self,
    #[body] input: UpdateProfile,
    #[file(name = "avatar")] avatar: Option<UploadedFile>,
) -> Result<Response<Profile>> {
    if let Some(avatar) = avatar {
        self.profiles.replace_avatar(avatar).await?;
    }
    Ok(Response::Body(self.profiles.update(input).await?))
}
}

Use Vec<UploadedFile> for repeated parts under one field name:

#![allow(unused)]
fn main() {
#[post("/attachments")]
async fn attach(
    &self,
    #[files(name = "attachment")] attachments: Vec<UploadedFile>,
) -> Result<Response<AttachmentSummary>> {
    // `attachments` is empty when the field is absent.
    Ok(Response::Body(self.files.store_all(attachments).await?))
}
}

Full-form access

Use #[multipart] MultipartForm when the controller needs direct control over repeated text or file fields instead of binding a DTO. It cannot be combined with #[body], #[file], or #[files] on the same route.

#![allow(unused)]
fn main() {
#[post("/import")]
async fn import(&self, #[multipart] mut form: MultipartForm) -> Result<Response<ImportResult>> {
    let mode = form.text("mode").and_then(|values| values.first());
    let files = form.take_files("source");
    Ok(Response::Body(self.importer.run(mode, files).await?))
}
}

text(name) and files(name) borrow the form. take_file(name) consumes one file and returns 400 Bad Request if that field has duplicates; take_files consumes every file with that name.

Uploaded file lifecycle

UploadedFile stages each file in Caelix’s isolated temporary directory. The handle exposes its multipart field name, client filename, optional content type, headers, byte size, and temporary path.

#![allow(unused)]
fn main() {
let bytes = upload.read_bytes().await?;
let destination = upload.persist_to("/srv/documents/report.pdf").await?;
}

These operations must happen in the opposite order when both are required: persist_to consumes the upload handle, so call read_bytes first. Temporary files are removed when their UploadedFile is dropped. persist_to requires the destination directory to exist and uses no-overwrite creation; it returns an error rather than replacing an existing file. Caelix supplies temporary upload handling only—applications choose permanent local, object-storage, or database storage themselves.

Limits and temporary storage

All multipart requests use Application::body_limit, which defaults to 1 MiB. Configure a larger application limit and an application-owned staging directory before listen:

#![allow(unused)]
fn main() {
Application::new::<AppModule>()
    .await?
    .body_limit(10 * 1024 * 1024)
    .upload_temp_dir("/var/tmp/my-service/uploads")
    .listen("127.0.0.1:3000")
    .await?;
}

Apply a stricter multipart limit to one route with #[upload(limit = ...)]:

#![allow(unused)]
fn main() {
#[upload(limit = 512 * 1024)]
#[post("/avatar")]
async fn avatar(&self, #[file] image: UploadedFile) -> Result<Response<Avatar>> {
    // ...
}
}

An overflow returns 413 Payload Too Large and includes the effective limit in the response message. Storage failures are logged with their details but expose only Caelix’s standard internal-server-error response to clients. TestApplication offers the same .body_limit(...) and .upload_temp_dir(...) configuration.

Upload support is opt-in. Enable the uploads feature when an application uses multipart extractor APIs; builds without it omit the multipart dependencies entirely.

Testing with curl

This request exercises typed DTO binding, repeated values, file metadata, and the normal validation path:

curl --fail-with-body \
  -F 'title=Quarterly report' \
  -F 'quantity=42' \
  -F 'published=true' \
  -F 'labels=rust' \
  -F 'labels=uploads' \
  -F 'document=@report.pdf;type=application/pdf' \
  http://127.0.0.1:3000/documents

Use raw multipart payloads through TestApplication::set_payload(...) when an integration test needs malformed-boundary or duplicate-file coverage. The Testing recipe shows the in-process request helpers.