Add multipart interaction form extraction
This commit is contained in:
Generated
+73
@@ -39,6 +39,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@@ -85,6 +86,12 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cobs"
|
name = "cobs"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -106,6 +113,15 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -199,6 +215,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -233,6 +255,23 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -381,6 +420,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"slhx-core",
|
"slhx-core",
|
||||||
"slhx-js",
|
"slhx-js",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -418,6 +458,12 @@ dependencies = [
|
|||||||
"slhx-core",
|
"slhx-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.9.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "streaming-iterator"
|
name = "streaming-iterator"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -461,6 +507,27 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.52.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -521,6 +588,12 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ edition.workspace = true
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", default-features = false }
|
axum = { version = "0.7", default-features = false, features = ["multipart"] }
|
||||||
slhx-core = { path = "../slhx-core" }
|
slhx-core = { path = "../slhx-core" }
|
||||||
slhx-js = { path = "../slhx-js" }
|
slhx-js = { path = "../slhx-js" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["macros", "rt"] }
|
||||||
|
|||||||
+117
-5
@@ -1,6 +1,6 @@
|
|||||||
use axum::async_trait;
|
use axum::async_trait;
|
||||||
use axum::body::{to_bytes, Body};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::extract::{FromRequest, FromRequestParts};
|
use axum::extract::{FromRequest, FromRequestParts, Multipart};
|
||||||
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
|
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
|
||||||
@@ -107,10 +107,19 @@ pub struct EffectResponse {
|
|||||||
pub batch: EffectBatch,
|
pub batch: EffectBatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InteractionFile {
|
||||||
|
pub name: String,
|
||||||
|
pub file_name: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct InteractionForm {
|
pub struct InteractionForm {
|
||||||
pub handle_id: u32,
|
pub handle_id: u32,
|
||||||
fields: Vec<(String, String)>,
|
fields: Vec<(String, String)>,
|
||||||
|
files: Vec<InteractionFile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct HandlerRegistry {
|
pub struct HandlerRegistry {
|
||||||
@@ -143,11 +152,52 @@ impl InteractionForm {
|
|||||||
Self {
|
Self {
|
||||||
handle_id,
|
handle_id,
|
||||||
fields: fields.into_iter().collect(),
|
fields: fields.into_iter().collect(),
|
||||||
|
files: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
|
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
|
||||||
let fields = parse_urlencoded_pairs(body)?;
|
Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: multipart/001, req: multipart/002
|
||||||
|
pub async fn parse_multipart(mut multipart: Multipart) -> Result<Self, InteractionFormRejection> {
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidBody)?
|
||||||
|
{
|
||||||
|
let Some(name) = field.name().map(str::to_owned) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let file_name = field.file_name().map(str::to_owned);
|
||||||
|
let content_type = field.content_type().map(str::to_owned);
|
||||||
|
let bytes = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||||
|
|
||||||
|
if file_name.is_some() {
|
||||||
|
files.push(InteractionFile {
|
||||||
|
name,
|
||||||
|
file_name,
|
||||||
|
content_type,
|
||||||
|
bytes: bytes.to_vec(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let value = String::from_utf8(bytes.to_vec())
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||||
|
fields.push((name, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::from_parts(fields, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(fields: Vec<(String, String)>, files: Vec<InteractionFile>) -> Result<Self, InteractionFormRejection> {
|
||||||
let Some(handle) = fields
|
let Some(handle) = fields
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
|
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
|
||||||
@@ -157,7 +207,7 @@ impl InteractionForm {
|
|||||||
let handle_id = handle
|
let handle_id = handle
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
||||||
Ok(Self { handle_id, fields })
|
Ok(Self { handle_id, fields, files })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn value(&self, name: &str) -> Option<&str> {
|
pub fn value(&self, name: &str) -> Option<&str> {
|
||||||
@@ -175,6 +225,14 @@ impl InteractionForm {
|
|||||||
pub fn fields(&self) -> &[(String, String)] {
|
pub fn fields(&self) -> &[(String, String)] {
|
||||||
&self.fields
|
&self.fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn files(&self) -> &[InteractionFile] {
|
||||||
|
&self.files
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file(&self, name: &str) -> Option<&InteractionFile> {
|
||||||
|
self.files.iter().find(|file| file.name == name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HandlerRegistry {
|
impl HandlerRegistry {
|
||||||
@@ -219,7 +277,7 @@ impl HandlerRegistry {
|
|||||||
impl IntoResponse for InteractionFormRejection {
|
impl IntoResponse for InteractionFormRejection {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
let (status, message) = match self {
|
let (status, message) = match self {
|
||||||
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid urlencoded slhx form body"),
|
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid slhx form body"),
|
||||||
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h slhx handle field"),
|
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h slhx handle field"),
|
||||||
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h slhx handle field"),
|
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h slhx handle field"),
|
||||||
};
|
};
|
||||||
@@ -235,6 +293,13 @@ where
|
|||||||
type Rejection = InteractionFormRejection;
|
type Rejection = InteractionFormRejection;
|
||||||
|
|
||||||
async fn from_request(req: Request<Body>, _state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request(req: Request<Body>, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
if is_multipart(req.headers()) {
|
||||||
|
let multipart = Multipart::from_request(req, _state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||||
|
return Self::parse_multipart(multipart).await;
|
||||||
|
}
|
||||||
|
|
||||||
let bytes = to_bytes(req.into_body(), 1024 * 1024)
|
let bytes = to_bytes(req.into_body(), 1024 * 1024)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||||
@@ -242,6 +307,18 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_multipart(headers: &HeaderMap) -> bool {
|
||||||
|
headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|content_type| {
|
||||||
|
content_type
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("multipart/form-data"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl PageMode {
|
impl PageMode {
|
||||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||||
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) {
|
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) {
|
||||||
@@ -402,7 +479,8 @@ fn hex(byte: u8) -> Option<u8> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{html_with_root_fingerprint, BuildFingerprint};
|
use super::{html_with_root_fingerprint, BuildFingerprint, InteractionForm};
|
||||||
|
use axum::{body::Body, extract::FromRequest, http::Request};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn root_fingerprint_is_added_to_initial_root() {
|
fn root_fingerprint_is_added_to_initial_root() {
|
||||||
@@ -426,4 +504,38 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(html, "<main data-slhx-root data-slhx-fp=\"1\">Docs</main>");
|
assert_eq!(html, "<main data-slhx-root data-slhx-fp=\"1\">Docs</main>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interaction_form_extracts_multipart_fields_and_files() {
|
||||||
|
let boundary = "slhx-test-boundary";
|
||||||
|
let body = concat!(
|
||||||
|
"--slhx-test-boundary\r\n",
|
||||||
|
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
|
||||||
|
"7\r\n",
|
||||||
|
"--slhx-test-boundary\r\n",
|
||||||
|
"Content-Disposition: form-data; name=\"title\"\r\n\r\n",
|
||||||
|
"Report\r\n",
|
||||||
|
"--slhx-test-boundary\r\n",
|
||||||
|
"Content-Disposition: form-data; name=\"upload\"; filename=\"a.txt\"\r\n",
|
||||||
|
"Content-Type: text/plain\r\n\r\n",
|
||||||
|
"hello\r\n",
|
||||||
|
"--slhx-test-boundary--\r\n",
|
||||||
|
);
|
||||||
|
let request = Request::builder()
|
||||||
|
.header(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
format!("multipart/form-data; boundary={boundary}"),
|
||||||
|
)
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let form = InteractionForm::from_request(request, &()).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(form.handle_id, 7);
|
||||||
|
assert_eq!(form.value("title"), Some("Report"));
|
||||||
|
let file = form.file("upload").unwrap();
|
||||||
|
assert_eq!(file.file_name.as_deref(), Some("a.txt"));
|
||||||
|
assert_eq!(file.content_type.as_deref(), Some("text/plain"));
|
||||||
|
assert_eq!(file.bytes, b"hello");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user