Impl OptionalFromRequest on MultipartFile and change behaviour on MultipartFiles to contain 0 files

This commit is contained in:
2025-09-06 15:28:24 +02:00
parent e0500b8e97
commit 113011399b
6 changed files with 1201 additions and 572 deletions

1571
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ exclude = ["examples"]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.89"
authors = ["Martin Berg Alstad"] authors = ["Martin Berg Alstad"]
homepage = "martials.no" homepage = "martials.no"
@ -60,7 +60,7 @@ derive_more = { workspace = true, features = ["from", "constructor"] }
tokio = "1.40" tokio = "1.40"
# Database # Database
diesel = "2.2" diesel = "2.2"
diesel-async = "0.5" diesel-async = "0.6"
diesel_migrations = "2.2" diesel_migrations = "2.2"
deadpool-diesel = "0.6" deadpool-diesel = "0.6"
# Error handling # Error handling
@ -71,13 +71,13 @@ quote = "1.0"
deluxe = "0.5" deluxe = "0.5"
proc-macro2 = "1.0" proc-macro2 = "1.0"
# Test # Test
testcontainers-modules = "0.11" testcontainers-modules = "0.13"
# Utils # Utils
derive_more = "2.0" derive_more = "2.0"
regex = "1.11" regex = "1.11"
[features] [features]
axum = ["dep:axum", "dep:tower", "dep:tower-http", "dep:thiserror", "dep:tracing", "dep:tracing-subscriber", "dep:tokio", "dep:mime"] axum = ["dep:axum", "dep:tower", "dep:serde", "dep:tower-http", "dep:thiserror", "dep:tracing", "dep:tracing-subscriber", "dep:tokio", "dep:mime"]
diesel = ["dep:diesel-crud-trait", "dep:diesel", "dep:diesel-async", "dep:deadpool-diesel", "dep:diesel_migrations"] diesel = ["dep:diesel-crud-trait", "dep:diesel", "dep:diesel-async", "dep:deadpool-diesel", "dep:diesel_migrations"]
io = ["dep:tokio", "dep:tokio-util"] io = ["dep:tokio", "dep:tokio-util"]
iter = [] iter = []

View File

@ -17,58 +17,13 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "async-trait"
version = "0.1.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "axum"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf"
dependencies = [
"async-trait",
"axum-core 0.4.3",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit 0.7.3",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper 1.0.1",
"tokio",
"tower 0.4.13",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.4" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
dependencies = [ dependencies = [
"axum-core 0.5.2", "axum-core",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@ -78,7 +33,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"itoa", "itoa",
"matchit 0.8.4", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer", "multer",
@ -89,7 +44,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper 1.0.1", "sync_wrapper",
"tokio", "tokio",
"tower 0.5.2", "tower 0.5.2",
"tower-layer", "tower-layer",
@ -97,27 +52,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-core"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper 0.1.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "axum-core" name = "axum-core"
version = "0.5.2" version = "0.5.2"
@ -132,7 +66,7 @@ dependencies = [
"mime", "mime",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
"sync_wrapper 1.0.1", "sync_wrapper",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@ -375,9 +309,10 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
name = "lib" name = "lib"
version = "2.0.0" version = "2.0.0"
dependencies = [ dependencies = [
"axum 0.8.4", "axum",
"derive_more", "derive_more",
"mime", "mime",
"serde",
"thiserror", "thiserror",
"tokio", "tokio",
"tower 0.5.2", "tower 0.5.2",
@ -398,12 +333,6 @@ version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@ -464,7 +393,7 @@ dependencies = [
name = "multipart_file" name = "multipart_file"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum 0.7.5", "axum",
"lib", "lib",
"tokio", "tokio",
] ]
@ -675,12 +604,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "sync_wrapper"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.1" version = "1.0.1"
@ -758,7 +681,6 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@ -770,7 +692,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"pin-project-lite", "pin-project-lite",
"sync_wrapper 1.0.1", "sync_wrapper",
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",

View File

@ -1,9 +1,9 @@
[package] [package]
name = "multipart_file" name = "multipart_file"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
lib = { path = "../..", features = ["axum"] } lib = { path = "../..", features = ["axum"] }
axum = "0.7.5" axum = "0.8"
tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] } tokio = { version = "1.47", features = ["rt-multi-thread", "macros"] }

View File

@ -1,22 +1,10 @@
use axum::extract::DefaultBodyLimit; use axum::extract::DefaultBodyLimit;
use lib::axum::app::AppBuilder; use lib::axum::app::AppBuilder;
use lib::axum::extractor::MultipartFiles; use lib::axum::extractor::{MultipartFile, MultipartFiles};
use lib::routes; use lib::routes;
// 0 or more // 0 or more files
async fn with_optional_file(files: Option<MultipartFiles>) -> String { async fn several_files(MultipartFiles(files): MultipartFiles) -> String {
format!(
"{:?}",
files.map(|files| files
.0
.into_iter()
.map(|file| file.filename)
.collect::<Vec<_>>())
)
}
// 1 or more files
async fn handler(MultipartFiles(files): MultipartFiles) -> String {
format!( format!(
"{:?} uploaded", "{:?} uploaded",
files files
@ -26,11 +14,26 @@ async fn handler(MultipartFiles(files): MultipartFiles) -> String {
) )
} }
// 1 file exactly
async fn single_file(MultipartFile(file): MultipartFile) -> String {
format!("{:?} uploaded", file.filename)
}
// 0 or 1 file
async fn optional_single_file(file: Option<MultipartFile>) -> String {
format!(
"{:?} uploaded",
file.map(|file| file.0.filename)
.unwrap_or(String::from("No file found"))
)
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let route = routes!( let route = routes!(
get "/" => handler, get "/" => several_files,
get "/opt" => with_optional_file get "/file" => single_file,
get "/opt/file" => optional_single_file
) )
.layer(DefaultBodyLimit::disable()); .layer(DefaultBodyLimit::disable());
AppBuilder::new().route(route).serve().await.unwrap(); AppBuilder::new().route(route).serve().await.unwrap();

View File

@ -1,10 +1,11 @@
use axum::{ use axum::extract::FromRequest;
extract::{ use axum::extract::Multipart;
FromRequest, Multipart, Request, use axum::extract::OptionalFromRequest;
multipart::{Field, MultipartError, MultipartRejection}, use axum::extract::Request;
}, use axum::extract::multipart::Field;
response::IntoResponse, use axum::extract::multipart::MultipartError;
}; use axum::extract::multipart::MultipartRejection;
use axum::response::IntoResponse;
use mime::Mime; use mime::Mime;
use std::str::FromStr; use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
@ -140,6 +141,40 @@ where
} }
} }
impl<S> OptionalFromRequest<S> for MultipartFile
where
S: Send + Sync,
{
type Rejection = MultipartFileRejection;
/// Extracts a single file from a multipart request.
/// Expects exactly one file. A file must have a name, bytes and optionally a content type.
/// This extractor consumes the request and must ble placed last in the handler.
/// # Example
/// ```
/// use std::io::Read;
/// use axum::response::Html;
/// use lib::axum::extractor::{MultipartFile, MultipartFiles};
///
/// async fn upload_file(opt_file: Option<MultipartFile>) -> Html<String> {
/// Html(opt_file
/// .map(|MultipartFile(file)| String::from_utf8(file.bytes).unwrap())
/// .unwrap_or_else(|| String::from("<p>Not Found</p>"))
/// )
/// }
/// ```
async fn from_request(req: Request, state: &S) -> Result<Option<Self>, Self::Rejection> {
let multipart = Multipart::from_request(req, state).await?;
let files = get_files(multipart).await?;
if files.len() > 1 {
Err(MultipartFileRejection::SeveralFiles)
} else {
let file = files.first().ok_or(MultipartFileRejection::NoFiles)?;
Ok(Some(MultipartFile(file.clone())))
}
}
}
impl<S> FromRequest<S> for MultipartFiles impl<S> FromRequest<S> for MultipartFiles
where where
S: Send + Sync, S: Send + Sync,
@ -147,7 +182,7 @@ where
type Rejection = MultipartFileRejection; type Rejection = MultipartFileRejection;
/// Extracts multiple files from a multipart request. /// Extracts multiple files from a multipart request.
/// Expects at least one file. A file must have a name, bytes and optionally a content type. /// Can contain 0 files. A file must have a name, bytes and optionally a content type.
/// This extractor consumes the request and must ble placed last in the handler. /// This extractor consumes the request and must ble placed last in the handler.
/// # Example /// # Example
/// ``` /// ```
@ -167,12 +202,8 @@ where
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let multipart = Multipart::from_request(req, state).await?; let multipart = Multipart::from_request(req, state).await?;
let files = get_files(multipart).await?; let files = get_files(multipart).await?;
if files.is_empty() {
Err(MultipartFileRejection::NoFiles)
} else {
Ok(MultipartFiles(files)) Ok(MultipartFiles(files))
} }
}
} }
async fn get_files(mut multipart: Multipart) -> Result<Vec<File>, MultipartFileRejection> { async fn get_files(mut multipart: Multipart) -> Result<Vec<File>, MultipartFileRejection> {