How to Implement File Uploads and Handle Multipart Form Data in Topcoat

Topcoat handles multipart uploads through the Multipart extractor in topcoat-router, which streams form fields asynchronously when the multipart Cargo feature is enabled.

Topcoat is a modular Rust web framework built on Tokio. Its multipart support is isolated behind a feature flag to keep core dependencies minimal, leveraging the multer crate for boundary parsing and streaming. When enabled, the Multipart type acts as a request extractor that integrates directly with Topcoat's routing macros.

Enabling Multipart Support

Before handling file uploads, activate the multipart functionality in your Cargo.toml. The extractor is defined in crates/topcoat-router/src/content/multipart.rs but only compiled when the feature is present:

[dependencies]
topcoat = { version = "0.1", features = ["multipart"] }

Once enabled, the Multipart type becomes available for import from topcoat::router.

The Multipart Extractor

The Multipart struct implements both FromRequest and OptionalFromRequest traits. This allows handlers to require a multipart body or accept it optionally when the request might contain other content types.

During extraction, Topcoat inspects the Content-Type header and parses the boundary token using multer::parse_boundary (see the from_request implementation in crates/topcoat-router/src/content/multipart.rs). If the header is missing or the boundary is malformed, the extractor automatically returns a 400 Bad Request with the invalid_boundary error code.

Processing Upload Fields

After extraction, iterate over form parts using multipart.next_field().await?. This method yields an optional Field struct, which provides metadata and streaming access to each part's payload:

  • name() – Returns the form field name.
  • file_name() – Returns the original filename when present.
  • content_type() – Returns the MIME type of the part.
  • bytes() – Loads the entire field into memory as Bytes.
  • text() – Interprets the field as a UTF-8 string.
  • chunk() – Streams the field chunk-by-chunk for large files.

Additionally, Field implements Stream<Item = Result<Bytes>>, allowing you to apply standard stream combinators for backpressure-aware processing.

Complete Implementation Examples

Basic Upload Handler

This handler receives a multipart request and prints the size of each part:

use topcoat::{
    Result,
    router::{Multipart, route},
};

#[route(POST "/api/upload")]
async fn upload(mut multipart: Multipart) -> Result<&'static str> {
    while let Some(field) = multipart.next_field().await? {
        let name = field.name().unwrap_or("<unnamed>");
        let filename = field.file_name().unwrap_or("<no-file>");
        let data = field.bytes().await?;
        println!("field `{name}` ({filename}) → {} bytes", data.len());
    }
    Ok("upload complete")
}

Streaming Large Files to Disk

For production scenarios, avoid loading entire files into memory by streaming chunks directly to the filesystem:

use std::{path::PathBuf, io::Result as IoResult};
use topcoat::{router::{Multipart, route}, Result};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;

#[route(POST "/files")]
async fn save_files(mut multipart: Multipart) -> Result<&'static str> {
    while let Some(mut field) = multipart.next_field().await? {
        let filename = field
            .file_name()
            .map(|s| s.to_string())
            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());

        let mut file = File::create(PathBuf::from("uploads").join(&filename)).await?;
        while let Some(chunk) = field.chunk().await? {
            file.write_all(&chunk).await?;
        }
    }
    Ok("files stored")
}

Optional Multipart Handling

When a route might receive either multipart or JSON data, use Option<Multipart>:

use topcoat::{
    router::{Multipart, route},
    Result,
};

#[route(POST "/maybe")]
async fn maybe_upload(multipart: Option<Multipart>) -> Result<&'static str> {
    if let Some(mut mp) = multipart {
        while let Some(field) = mp.next_field().await? {
            let data = field.bytes().await?;
            println!("received {} bytes", data.len());
        }
        Ok("processed multipart")
    } else {
        Ok("no files submitted")
    }
}

Testing with cURL

Verify your implementation using standard multipart form syntax:

curl -X POST http://localhost:3000/api/upload \
     -F "photo=@/path/to/image.png" \
     -F "description=My picture"

This sends two parts: a file named photo and a text field named description.

Summary

  • Enable the feature: Add multipart to your topcoat dependency to compile the extractor.
  • Use the extractor: Import Multipart from topcoat::router and declare it as a handler parameter.
  • Handle errors: Topcoat returns 400 Bad Request automatically for invalid boundaries.
  • Stream efficiently: Use chunk() or the Stream implementation to process large files without exhausting memory.
  • Make it optional: Use Option<Multipart> for endpoints that accept variable content types.

Frequently Asked Questions

What happens if the Content-Type header is missing or malformed?

Topcoat returns a 400 Bad Request with the error kind invalid_boundary. This occurs automatically during the FromRequest implementation in crates/topcoat-router/src/content/multipart.rs before your handler code executes.

Can I process multipart uploads without loading the entire file into memory?

Yes. The Field type implements Stream<Item = Result<Bytes>>, allowing you to call field.chunk().await? in a loop or use any Tokio-compatible stream consumer. This approach is essential for handling gigabyte-scale uploads without allocating large buffers.

Is the Multipart extractor available for all HTTP methods?

The extractor works with any HTTP method, but it is most commonly used with POST and PUT requests. The #[route] macro accepts standard method annotations like POST "/upload" regardless of the extractor type.

How do I handle mixed form data containing both files and text fields?

The next_field() iterator yields each part sequentially regardless of content type. Inspect field.file_name() to distinguish files from text inputs, and use field.text().await? for form fields and field.bytes().await? or streaming for binary data. Both variants appear as Field instances during iteration.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →