Add multipart interaction form extraction
This commit is contained in:
@@ -7,6 +7,9 @@ edition.workspace = true
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", default-features = false }
|
||||
axum = { version = "0.7", default-features = false, features = ["multipart"] }
|
||||
slhx-core = { path = "../slhx-core" }
|
||||
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::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::response::IntoResponse;
|
||||
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
|
||||
@@ -107,10 +107,19 @@ pub struct EffectResponse {
|
||||
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)]
|
||||
pub struct InteractionForm {
|
||||
pub handle_id: u32,
|
||||
fields: Vec<(String, String)>,
|
||||
files: Vec<InteractionFile>,
|
||||
}
|
||||
|
||||
pub struct HandlerRegistry {
|
||||
@@ -143,11 +152,52 @@ impl InteractionForm {
|
||||
Self {
|
||||
handle_id,
|
||||
fields: fields.into_iter().collect(),
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.iter()
|
||||
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
|
||||
@@ -157,7 +207,7 @@ impl InteractionForm {
|
||||
let handle_id = handle
|
||||
.parse::<u32>()
|
||||
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
||||
Ok(Self { handle_id, fields })
|
||||
Ok(Self { handle_id, fields, files })
|
||||
}
|
||||
|
||||
pub fn value(&self, name: &str) -> Option<&str> {
|
||||
@@ -175,6 +225,14 @@ impl InteractionForm {
|
||||
pub fn fields(&self) -> &[(String, String)] {
|
||||
&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 {
|
||||
@@ -219,7 +277,7 @@ impl HandlerRegistry {
|
||||
impl IntoResponse for InteractionFormRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
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::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h slhx handle field"),
|
||||
};
|
||||
@@ -235,6 +293,13 @@ where
|
||||
type Rejection = InteractionFormRejection;
|
||||
|
||||
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)
|
||||
.await
|
||||
.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 {
|
||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||
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)]
|
||||
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]
|
||||
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>");
|
||||
}
|
||||
|
||||
#[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