Subida de archivos y streaming
Las subidas multipart/form-data se procesan con interceptores de archivos, al estilo de multer sobre Express: un interceptor lee el cuerpo, guarda cada archivo con un storage y deja los archivos disponibles para los parámetros #[uploaded_file] y #[uploaded_files]. Todo vive en corvis::multer, y los tipos más usados se reexportan en la raíz de corvis.
Un archivo
use corvis::prelude::*;
use corvis::{FileInterceptor, UploadedFile};
#[controller("files")]
pub struct FilesController;
#[routes]
impl FilesController {
#[post("avatar")]
#[use_interceptors(FileInterceptor::new("file"))]
fn avatar(&self, #[uploaded_file] file: Option<UploadedFile>, #[body] body: Value) -> Value {
json!({
"name": file.as_ref().map(|f| f.originalname.clone()),
"size": file.as_ref().map(|f| f.size),
"fields": body,
})
}
}FileInterceptor::new("file")acepta un archivo en el campofile. Un archivo en otro campo responde400 Unexpected field - <campo>.#[uploaded_file] file: Option<UploadedFile>recibeNonesi no llegó archivo. Confile: UploadedFile(sinOption) el archivo es obligatorio.#[body]recibe los campos de texto del formulario. Las clavestags[],user[name]oitems[0]y las claves repetidas se convierten en arrays y objetos, como conappend-field.
UploadedFile tiene los campos de un archivo de multer: fieldname, originalname, encoding, mimetype, size y, según el storage, buffer (memoria) o destination, filename y path (disco). file.bytes() devuelve el contenido cuando está en memoria.
Varios archivos
| Interceptor | Parámetro |
|---|---|
FilesInterceptor::new("photos").max_count(12) | #[uploaded_files] files: Vec<UploadedFile> |
FileFieldsInterceptor::new([MulterField::new("avatar").max_count(1), MulterField::new("docs")]) | #[uploaded_files] files: HashMap<String, Vec<UploadedFile>> |
AnyFilesInterceptor::new() | #[uploaded_files] files: Vec<UploadedFile> |
NoFilesInterceptor::new() | Solo campos de texto; cualquier archivo responde 400 |
use std::collections::HashMap;
use corvis::{FileFieldsInterceptor, MulterField};
#[post("profile")]
#[use_interceptors(FileFieldsInterceptor::new([MulterField::new("avatar").max_count(1), MulterField::new("docs")]))]
fn profile(&self, #[uploaded_files] files: HashMap<String, Vec<UploadedFile>>) -> Value {
json!({ "fields": files.keys().collect::<Vec<_>>() })
}Límites y opciones
Cada interceptor acepta .limits(..), .storage(..) y .options(MulterOptions):
use corvis::multer::{DiskStorageOptions, Limits, MulterOptions, disk_storage};
#[post(":id/evidence")]
#[use_interceptors(FileInterceptor::new("file").options(
MulterOptions::new()
.storage(disk_storage(
DiskStorageOptions::new()
.destination("./uploads")
.filename(|_req, file| Ok(format!("{}-{}", chrono::Utc::now().timestamp_millis(), file.originalname))),
))
.limits(Limits::new().file_size(5 * 1024 * 1024).files(1).fields(20).parts(25)),
))]
async fn upload(&self, #[param("id")] id: String, #[uploaded_file] file: UploadedFile) -> HttpResult<Value> {
Ok(json!({ "id": id, "path": file.path }))
}(El ejemplo usa el crate chrono para el nombre; cualquier generador de nombres sirve.)
Limits | Efecto al superarlo |
|---|---|
.file_size(bytes) | 413 File too large (también al alcanzar el tamaño, como busboy) |
.files(n) | 400 Too many files |
.fields(n), .parts(n) | 400 con el mensaje de multer |
.field_size(bytes) | 400 Field value too long - <campo> (1 MiB por defecto) |
.field_name_size(bytes) | Longitud máxima del nombre de un campo |
.field_nesting_depth(n) | Profundidad de corchetes en los nombres de campo |
Otras opciones de MulterOptions:
.dest("uploads"): atajo para guardar en disco con nombres aleatorios..file_filter(|req, file| ...):Ok(true)acepta,Ok(false)omite el archivo yErr(e)corta la subida con ese error..preserve_path(true)y.def_param_charset("utf8"): como en multer..body_limit(bytes): tope total del cuerpo multipart (10 MiB por defecto, ofile_size × files + 1 MiBsi loslimitspiden más). Se supera con413 request entity too large..remove_files_on_error(false): conserva en disco los archivos ya escritos si después falla un pipe o el handler (por defecto se borran).
Sin limits no hay tope de partes, campos ni archivos más allá de body_limit. En rutas públicas fija los que la ruta necesita.
Opciones por módulo
MulterModule::register(MulterOptions::new()...) (o register_async) define las opciones por defecto de los interceptores de los controladores del módulo que lo importa.
Storage
memory_storage()(por defecto): el contenido queda enfile.buffer.disk_storage(DiskStorageOptions::new().destination(..).filename(..)): escribe en disco.destination_fndecide la carpeta por archivo. El nombre devuelto porfilenamese valida y, si sale dedestination(.., rutas absolutas, unidades, flujos NTFS, nombres reservados de Windows), la subida responde400 Invalid file name. Aun así, no usesfile.originalnametal cual como nombre en disco.- Un storage propio implementa el trait
StorageEngine(handle_fileyremove_file).
Validar el archivo con ParseFilePipe
#[uploaded_file(...)] acepta pipes. ParseFilePipe ejecuta validadores sobre el archivo:
use corvis::{FileTypeValidator, MaxFileSizeValidator, ParseFilePipe};
#[post("validated")]
#[use_interceptors(FileInterceptor::new("file"))]
fn validated(
&self,
#[uploaded_file(ParseFilePipe::new()
.validator(MaxFileSizeValidator::new(1_000_000))
.validator(FileTypeValidator::new("image/png")))]
file: UploadedFile,
) -> Value {
json!({ "ok": file.originalname })
}MaxFileSizeValidator::new(bytes)con.message(..)o.error_message(..).FileTypeValidator::new("image/(png|jpeg)")detecta el tipo real por la firma del archivo (magic numbers). Para tipos de texto sin firma usa.fallback_to_mimetype(true)o.skip_magic_numbers_validation(true).ParseFilePipeacepta.file_is_required(false),.error_http_status_code(422)y.exception_factory(..).ParseFilePipeBuilder::new().add_max_size_validator(..).add_file_type_validator(..).build(ParseFilePipe::new())es la forma con builder.- Un validador propio implementa
FileValidator(is_validybuild_error_message).
La firma no garantiza que el archivo sea inocuo
FileTypeValidator solo mira el inicio del archivo: un archivo políglota puede pasar como imagen. Al servir subidas, usa un Content-Type fijo, Content-Disposition: attachment y X-Content-Type-Options: nosniff.
Cuándo se lee el cuerpo
El interceptor solo actúa si la petición trae Content-Type: multipart/*; si no, no hace nada. El cuerpo multipart no lo lee el servidor sino el interceptor, después de los guards y solo en las rutas que lo usan: una petición a una ruta sin interceptor, inexistente o rechazada por un guard se responde sin leer el cuerpo. La lectura es en streaming y los límites cortan la subida en cuanto se alcanzan.
Cada archivo, eso sí, se acumula en memoria hasta que termina su parte y se entrega entero al storage: no hay streaming directo a disco. Por eso existe body_limit.
Para procesar el multipart a mano, corvis::multer::read_body(&req, limite).await? devuelve el cuerpo completo.
Enviar archivos al cliente
Para el sentido contrario, devolver un archivo, usa StreamableFile:
#[get(":id/pdf")]
async fn pdf(&self, #[param("id", ParseUUIDPipe)] id: String) -> HttpResult<StreamableFile> {
let file = StreamableFile::from_path(format!("./reports/{id}.pdf"))
.await
.map_err(|_| NotFoundException::new("Report not found"))?;
Ok(file.content_type("application/pdf").filename(format!("{id}.pdf")))
}from_path lee el archivo del disco por partes. Hay más formas en Respuestas en streaming.
Pruebas
El cliente de pruebas arma el multipart/form-data:
let r = request(&app)
.post("/files/avatar")
.field("title", "Hola")
.attach("file", "contenido", "notas.txt")
.send()
.await;