use std::marker::PhantomData;
use axum::{
body::Body,
extract::{FromRequest, Request},
};
use axum_extra::extract::multipart::Field;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde_json::json;
use super::file::{FileData, FileUpload};
use crate::extract::Rejection;
fn coerce_and_push_array(output: &mut serde_json::Value, key: String, value: serde_json::Value) {
match output.get_mut(&key) {
Some(serde_json::Value::Array(a)) => {
a.push(value);
}
Some(v) => {
let old = v.take();
*v = json!([old, value]);
}
None => {
output[key] = value;
}
}
}
enum MultipartField {
File(Field),
Data(String, String),
}
async fn handle_multipart_field(field: Field) -> Result<MultipartField, Rejection> {
let value = if field.file_name().is_some() {
MultipartField::File(field)
} else if let Some(name) = field.name() {
let name = name.to_string();
let data = field.text().await?;
MultipartField::Data(name, data)
} else {
MultipartField::File(field)
};
Ok(value)
}
pub async fn parse_multipart<T>(req: Request<Body>) -> Result<(T, Vec<FileUpload>), Rejection>
where
T: DeserializeOwned + JsonSchema + Send + Sync + 'static,
{
let mut files = Vec::new();
let multipart = axum_extra::extract::Multipart::from_request(req, &())
.await
.map_err(Rejection::Multipart)?;
let mut processor = MultipartProcessor::<T>::from(multipart);
while let Some(field) = processor.next_file().await? {
let content_type = field.content_type().unwrap_or_default().to_string();
let name = field.name().map(|s| s.to_string()).unwrap_or_default();
let filename = field.file_name().unwrap_or_default().to_string();
let data = field.bytes().await?;
files.push(FileUpload {
name,
filename,
content_type,
data: FileData(Vec::from(data)),
});
}
let output = processor.finish().await?;
Ok((output, files))
}
pub struct MultipartProcessor<T>
where
T: DeserializeOwned + JsonSchema + Send + Sync + 'static,
{
multipart: axum_extra::extract::Multipart,
data: serde_json::Value,
_marker: PhantomData<T>,
pub may_skip_files: bool,
}
impl<T> MultipartProcessor<T>
where
T: DeserializeOwned + JsonSchema + Send + Sync + 'static,
{
pub async fn next_file(&mut self) -> Result<Option<Field>, Rejection> {
while let Some(field) = self.multipart.next_field().await? {
match handle_multipart_field(field).await? {
MultipartField::File(field) => {
return Ok(Some(field));
}
MultipartField::Data(key, value) => {
coerce_and_push_array(&mut self.data, key, json!(value));
}
}
}
Ok(None)
}
pub async fn finish(mut self) -> Result<T, Rejection> {
while let Some(field) = self.multipart.next_field().await? {
match handle_multipart_field(field).await? {
MultipartField::File(_) => {
if !self.may_skip_files {
return Err(Rejection::TooManyFiles);
}
}
MultipartField::Data(key, value) => {
coerce_and_push_array(&mut self.data, key, json!(value));
}
}
}
let data = crate::requests::json_schema::validate::<T>(self.data, true)
.map_err(Rejection::Validation)?;
serde_path_to_error::deserialize(data).map_err(Rejection::Serde)
}
}
impl<T> From<axum_extra::extract::Multipart> for MultipartProcessor<T>
where
T: DeserializeOwned + JsonSchema + Send + Sync + 'static,
{
fn from(multipart: axum_extra::extract::Multipart) -> Self {
Self {
multipart,
data: json!({}),
_marker: PhantomData,
may_skip_files: false,
}
}
}
#[cfg(test)]
mod test {
use indoc::indoc;
use serde::Deserialize;
use super::*;
fn get_req() -> hyper::Request<axum::body::Body> {
let body = indoc! {r##"
--fieldB
Content-Disposition: form-data; name="name"
test
--fieldB
Content-Disposition: form-data; name="file1"; filename="a.txt"
Content-Type: text/plain
Some text
--fieldB
Content-Disposition: form-data; name="file2"; filename="a.html"
Content-Type: text/html
<b>Some html</b>
--fieldB
Content-Disposition: form-data; name="agreed"
on
--fieldB--
"##}
.replace("\n", "\r\n");
println!("{}", body);
hyper::Request::builder()
.header("content-type", "multipart/form-data; boundary=fieldB")
.header("content-length", body.len())
.body(axum::body::Body::from(body))
.unwrap()
}
#[tokio::test]
async fn parse_multipart_jsonvalue() {
let data = get_req();
let (value, files) = super::parse_multipart::<serde_json::Value>(data)
.await
.unwrap();
assert_eq!(
value,
json!({
"name": "test",
"agreed": "on"
})
);
assert_eq!(
files,
vec![
(FileUpload {
name: "file1".to_string(),
filename: "a.txt".to_string(),
content_type: "text/plain".to_string(),
data: FileData(Vec::from("Some text".as_bytes()))
}),
(FileUpload {
name: "file2".to_string(),
filename: "a.html".to_string(),
content_type: "text/html".to_string(),
data: FileData(Vec::from("<b>Some html</b>".as_bytes()))
}),
]
);
}
#[tokio::test]
async fn parse_multipart_serde() {
#[derive(Deserialize, JsonSchema, Debug, PartialEq, Eq)]
struct Data {
name: String,
agreed: bool,
}
let data = get_req();
let (value, files) = super::parse_multipart::<Data>(data).await.unwrap();
assert_eq!(
value,
Data {
name: "test".to_string(),
agreed: true
}
);
assert_eq!(
files,
vec![
(FileUpload {
name: "file1".to_string(),
filename: "a.txt".to_string(),
content_type: "text/plain".to_string(),
data: FileData(Vec::from("Some text".as_bytes()))
}),
(FileUpload {
name: "file2".to_string(),
filename: "a.html".to_string(),
content_type: "text/html".to_string(),
data: FileData(Vec::from("<b>Some html</b>".as_bytes()))
}),
]
);
}
#[tokio::test]
async fn multipart_processor() {
let req = get_req();
#[derive(Deserialize, JsonSchema, Debug, PartialEq, Eq)]
struct Data {
name: String,
agreed: bool,
}
let multipart = axum_extra::extract::Multipart::from_request(req, &())
.await
.unwrap();
let mut processor = super::MultipartProcessor::<Data>::from(multipart);
let mut index = 0;
while let Some(file) = processor.next_file().await.unwrap() {
match index {
0 => {
assert_eq!(file.file_name(), Some("a.txt"));
assert_eq!(file.content_type(), Some("text/plain"));
assert_eq!(file.name(), Some("file1"));
assert_eq!(file.text().await.unwrap(), "Some text");
}
1 => {
assert_eq!(file.file_name(), Some("a.html"));
assert_eq!(file.content_type(), Some("text/html"));
assert_eq!(file.name(), Some("file2"));
assert_eq!(file.text().await.unwrap(), "<b>Some html</b>");
}
_ => panic!("Saw too many files"),
};
index += 1;
}
let output = processor.finish().await.unwrap();
assert_eq!(
output,
Data {
name: "test".to_string(),
agreed: true
}
);
}
#[tokio::test]
async fn multipart_processor_too_many_uploads() {
let req = get_req();
#[derive(Deserialize, JsonSchema, Debug, PartialEq, Eq)]
struct Data {
name: String,
agreed: bool,
}
let multipart = axum_extra::extract::Multipart::from_request(req, &())
.await
.unwrap();
let mut processor = super::MultipartProcessor::<Data>::from(multipart);
let file = processor.next_file().await.unwrap().unwrap();
assert_eq!(file.file_name(), Some("a.txt"));
assert_eq!(file.content_type(), Some("text/plain"));
assert_eq!(file.name(), Some("file1"));
assert_eq!(file.text().await.unwrap(), "Some text");
let err = processor.finish().await.expect_err("Finishing");
assert!(matches!(err, Rejection::TooManyFiles));
}
#[tokio::test]
async fn multipart_processor_may_skip_files() {
let req = get_req();
#[derive(Deserialize, JsonSchema, Debug, PartialEq, Eq)]
struct Data {
name: String,
agreed: bool,
}
let multipart = axum_extra::extract::Multipart::from_request(req, &())
.await
.unwrap();
let mut processor = super::MultipartProcessor::<Data>::from(multipart);
processor.may_skip_files = true;
let file = processor.next_file().await.unwrap().unwrap();
assert_eq!(file.file_name(), Some("a.txt"));
assert_eq!(file.content_type(), Some("text/plain"));
assert_eq!(file.name(), Some("file1"));
assert_eq!(file.text().await.unwrap(), "Some text");
let output = processor.finish().await.unwrap();
assert_eq!(
output,
Data {
name: "test".to_string(),
agreed: true
}
);
}
}