From 2ccbb27f0291907568fd03c4b775faf812f1bc81 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sun, 15 May 2022 13:01:47 +0200 Subject: [PATCH 1/6] Add `axum_extra::extra::Form` --- axum-extra/CHANGELOG.md | 2 +- axum-extra/Cargo.toml | 2 + axum-extra/src/extract/form.rs | 124 +++++++++++++++++++++++++++ axum-extra/src/extract/mod.rs | 7 ++ axum/CHANGELOG.md | 1 + axum/src/extract/form.rs | 4 +- axum/src/extract/has_content_type.rs | 24 ++++++ axum/src/extract/mod.rs | 23 +---- axum/src/extract/query.rs | 2 +- axum/src/extract/rejection.rs | 5 +- 10 files changed, 168 insertions(+), 26 deletions(-) create mode 100644 axum-extra/src/extract/form.rs create mode 100644 axum/src/extract/has_content_type.rs diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index 0175d6f690..6784d878af 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning]. # Unreleased -- None. +- **added:** Add `extract::Form` which supports multi-value items # 0.3.1 (10. May, 2022) diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index b5c8c1a6cc..1af3edc317 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -18,6 +18,7 @@ cookie = ["cookie-lib"] cookie-signed = ["cookie", "cookie-lib/signed"] cookie-private = ["cookie", "cookie-lib/private"] spa = ["tower-http/fs"] +form = ["serde", "serde_html_form"] [dependencies] axum = { path = "../axum", version = "0.5", default-features = false } @@ -36,6 +37,7 @@ serde = { version = "1.0", optional = true } serde_json = { version = "1.0.71", optional = true } percent-encoding = { version = "2.1", optional = true } cookie-lib = { package = "cookie", version = "0.16", features = ["percent-encode"], optional = true } +serde_html_form = { version = "0.1", optional = true } [dev-dependencies] axum = { path = "../axum", version = "0.5", features = ["headers"] } diff --git a/axum-extra/src/extract/form.rs b/axum-extra/src/extract/form.rs new file mode 100644 index 0000000000..9544dff424 --- /dev/null +++ b/axum-extra/src/extract/form.rs @@ -0,0 +1,124 @@ +use axum::{ + async_trait, + body::HttpBody, + extract::{ + rejection::{FailedToDeserializeQueryString, FormRejection, InvalidFormContentType}, + FromRequest, RequestParts, + }, + BoxError, +}; +use bytes::Bytes; +use http::Method; +use serde::de::DeserializeOwned; +use std::ops::Deref; + +/// Extractor that deserializes `application/x-www-form-urlencoded` requests +/// into some type. +/// +/// `T` is expected to implement [`serde::Deserialize`]. +/// +/// # Differences from `axum::extract::Form` +/// +/// This extractor uses [`serde_html_form`] under-the-hood which supports multi-value items. These +/// are sent by HTML checkboxes and multi selects. Those values can be collected into a `Vec`. +/// +/// # Example +/// +/// ```rust,no_run +/// use axum_extra::extract::Form; +/// use serde::Deserialize; +/// +/// #[derive(Deserialize)] +/// struct Payload { +/// #[serde(rename = "value")] +/// values: Vec, +/// } +/// +/// async fn accept_form(Form(payload): Form) { +/// // ... +/// } +/// ``` +/// +/// [`serde_html_form`]: https://crates.io/crates/serde_html_form +#[derive(Debug, Clone, Copy, Default)] +pub struct Form(pub T); + +impl Deref for Form { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait] +impl FromRequest for Form +where + T: DeserializeOwned, + B: HttpBody + Send, + B::Data: Send, + B::Error: Into, +{ + type Rejection = FormRejection; + + async fn from_request(req: &mut RequestParts) -> Result { + if req.method() == Method::GET { + let query = req.uri().query().unwrap_or_default(); + let value = serde_html_form::from_str(query) + .map_err(FailedToDeserializeQueryString::__private_new::)?; + Ok(Form(value)) + } else { + if !has_content_type::has_content_type(req, &mime::APPLICATION_WWW_FORM_URLENCODED) { + return Err(InvalidFormContentType::default().into()); + } + + let bytes = Bytes::from_request(req).await?; + let value = serde_html_form::from_bytes(&bytes) + .map_err(FailedToDeserializeQueryString::__private_new::)?; + + Ok(Form(value)) + } + } +} + +mod has_content_type { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../axum/src/extract/has_content_type.rs" + )); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::*; + use axum::{routing::post, Router}; + use http::{header::CONTENT_TYPE, StatusCode}; + use serde::Deserialize; + + #[tokio::test] + async fn supports_multiple_values() { + #[derive(Deserialize)] + struct Data { + #[serde(rename = "value")] + values: Vec, + } + + let app = Router::new().route( + "/", + post(|Form(data): Form| async move { data.values.join(",") }), + ); + + let client = TestClient::new(app); + + let res = client + .post("/") + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body("value=one&value=two") + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "one,two"); + } +} diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index ec5fe1ad97..4a98c7163c 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -1,6 +1,10 @@ //! Additional extractors. mod cached; + +#[cfg(feature = "form")] +mod form; + #[cfg(feature = "cookie")] pub mod cookie; @@ -14,3 +18,6 @@ pub use self::cookie::PrivateCookieJar; #[cfg(feature = "cookie-signed")] pub use self::cookie::SignedCookieJar; + +#[cfg(feature = "form")] +pub use self::form::Form; diff --git a/axum/CHANGELOG.md b/axum/CHANGELOG.md index 8e6045f06b..40d7b51c87 100644 --- a/axum/CHANGELOG.md +++ b/axum/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Path<(String, String)>` or `Path` ([#1023]) - **fixed:** `PathRejection::WrongNumberOfParameters` now uses `500 Internal Server Error` since its a programmer error and not a client error ([#1023]) +- **fixed:** Fix `InvalidFormContentType` mentioning the wrong content type [#1022]: https://github.com/tokio-rs/axum/pull/1022 [#1023]: https://github.com/tokio-rs/axum/pull/1023 diff --git a/axum/src/extract/form.rs b/axum/src/extract/form.rs index f47c40680d..4f0295afd0 100644 --- a/axum/src/extract/form.rs +++ b/axum/src/extract/form.rs @@ -58,7 +58,7 @@ where if req.method() == Method::GET { let query = req.uri().query().unwrap_or_default(); let value = serde_urlencoded::from_str(query) - .map_err(FailedToDeserializeQueryString::new::)?; + .map_err(FailedToDeserializeQueryString::__private_new::)?; Ok(Form(value)) } else { if !has_content_type(req, &mime::APPLICATION_WWW_FORM_URLENCODED) { @@ -67,7 +67,7 @@ where let bytes = Bytes::from_request(req).await?; let value = serde_urlencoded::from_bytes(&bytes) - .map_err(FailedToDeserializeQueryString::new::)?; + .map_err(FailedToDeserializeQueryString::__private_new::)?; Ok(Form(value)) } diff --git a/axum/src/extract/has_content_type.rs b/axum/src/extract/has_content_type.rs new file mode 100644 index 0000000000..79b40b7d9a --- /dev/null +++ b/axum/src/extract/has_content_type.rs @@ -0,0 +1,24 @@ +// this is in its own file such that we can share it between `axum` and `axum-extra` without making +// it part of the public API. We get into `axum-extra` using `include!`. + +use super::RequestParts; +use http::header; + +pub(crate) fn has_content_type( + req: &RequestParts, + expected_content_type: &mime::Mime, +) -> bool { + let content_type = if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) { + content_type + } else { + return false; + }; + + let content_type = if let Ok(content_type) = content_type.to_str() { + content_type + } else { + return false; + }; + + content_type.starts_with(expected_content_type.as_ref()) +} diff --git a/axum/src/extract/mod.rs b/axum/src/extract/mod.rs index cde177a732..cf2f1bb2dd 100644 --- a/axum/src/extract/mod.rs +++ b/axum/src/extract/mod.rs @@ -1,6 +1,5 @@ #![doc = include_str!("../docs/extract.md")] -use http::header; use rejection::*; pub mod connect_info; @@ -12,10 +11,13 @@ pub mod rejection; pub mod ws; mod content_length_limit; +mod has_content_type; mod host; mod raw_query; mod request_parts; +pub(crate) use has_content_type::has_content_type; + #[doc(inline)] pub use axum_core::extract::{FromRequest, RequestParts}; @@ -78,25 +80,6 @@ pub use self::ws::WebSocketUpgrade; #[doc(no_inline)] pub use crate::TypedHeader; -pub(crate) fn has_content_type( - req: &RequestParts, - expected_content_type: &mime::Mime, -) -> bool { - let content_type = if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) { - content_type - } else { - return false; - }; - - let content_type = if let Ok(content_type) = content_type.to_str() { - content_type - } else { - return false; - }; - - content_type.starts_with(expected_content_type.as_ref()) -} - pub(crate) fn take_body(req: &mut RequestParts) -> Result { req.take_body().ok_or_else(BodyAlreadyExtracted::default) } diff --git a/axum/src/extract/query.rs b/axum/src/extract/query.rs index bf675f667b..c267ce05fc 100644 --- a/axum/src/extract/query.rs +++ b/axum/src/extract/query.rs @@ -59,7 +59,7 @@ where async fn from_request(req: &mut RequestParts) -> Result { let query = req.uri().query().unwrap_or_default(); let value = serde_urlencoded::from_str(query) - .map_err(FailedToDeserializeQueryString::new::)?; + .map_err(FailedToDeserializeQueryString::__private_new::)?; Ok(Query(value)) } } diff --git a/axum/src/extract/rejection.rs b/axum/src/extract/rejection.rs index 4d13faee66..68e72e6beb 100644 --- a/axum/src/extract/rejection.rs +++ b/axum/src/extract/rejection.rs @@ -82,7 +82,7 @@ define_rejection! { define_rejection! { #[status = UNSUPPORTED_MEDIA_TYPE] - #[body = "Form requests must have `Content-Type: x-www-form-urlencoded`"] + #[body = "Form requests must have `Content-Type: application/x-www-form-urlencoded`"] /// Rejection type used if you try and extract the request more than once. pub struct InvalidFormContentType; } @@ -104,7 +104,8 @@ pub struct FailedToDeserializeQueryString { } impl FailedToDeserializeQueryString { - pub(super) fn new(error: E) -> Self + #[doc(hidden)] + pub fn __private_new(error: E) -> Self where E: Into, { From ef90c8fa1164c9a32d7e6cc3a0790513e6d8fb7b Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sun, 15 May 2022 13:25:23 +0200 Subject: [PATCH 2/6] update tokio-util ban --- deny.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deny.toml b/deny.toml index 6548faa214..33e74a8d72 100644 --- a/deny.toml +++ b/deny.toml @@ -46,8 +46,8 @@ skip-tree = [ ] skip = [ { name = "spin", version = "=0.5.2" }, - # old version pulled in by h2 - { name = "tokio-util", version = "=0.6.9" }, + # old version pulled in by reqwest which is only a dev dependency + { name = "tokio-util", version = "=0.6.10" }, ] [sources] From 2c5739c0897f1e6d745ae81cac3c4aed9b7d8147 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sun, 15 May 2022 16:15:45 +0200 Subject: [PATCH 3/6] remove `include!` hack --- axum-extra/src/extract/form.rs | 24 +++++++++++++++++------- axum/src/extract/has_content_type.rs | 24 ------------------------ axum/src/extract/mod.rs | 24 +++++++++++++++++++++--- 3 files changed, 38 insertions(+), 34 deletions(-) delete mode 100644 axum/src/extract/has_content_type.rs diff --git a/axum-extra/src/extract/form.rs b/axum-extra/src/extract/form.rs index 9544dff424..9797581203 100644 --- a/axum-extra/src/extract/form.rs +++ b/axum-extra/src/extract/form.rs @@ -8,7 +8,7 @@ use axum::{ BoxError, }; use bytes::Bytes; -use http::Method; +use http::{header, Method}; use serde::de::DeserializeOwned; use std::ops::Deref; @@ -68,7 +68,7 @@ where .map_err(FailedToDeserializeQueryString::__private_new::)?; Ok(Form(value)) } else { - if !has_content_type::has_content_type(req, &mime::APPLICATION_WWW_FORM_URLENCODED) { + if !has_content_type(req, &mime::APPLICATION_WWW_FORM_URLENCODED) { return Err(InvalidFormContentType::default().into()); } @@ -81,11 +81,21 @@ where } } -mod has_content_type { - include!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../axum/src/extract/has_content_type.rs" - )); +// this is duplicated in `axum/src/extract/mod.rs` +fn has_content_type(req: &RequestParts, expected_content_type: &mime::Mime) -> bool { + let content_type = if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) { + content_type + } else { + return false; + }; + + let content_type = if let Ok(content_type) = content_type.to_str() { + content_type + } else { + return false; + }; + + content_type.starts_with(expected_content_type.as_ref()) } #[cfg(test)] diff --git a/axum/src/extract/has_content_type.rs b/axum/src/extract/has_content_type.rs deleted file mode 100644 index 79b40b7d9a..0000000000 --- a/axum/src/extract/has_content_type.rs +++ /dev/null @@ -1,24 +0,0 @@ -// this is in its own file such that we can share it between `axum` and `axum-extra` without making -// it part of the public API. We get into `axum-extra` using `include!`. - -use super::RequestParts; -use http::header; - -pub(crate) fn has_content_type( - req: &RequestParts, - expected_content_type: &mime::Mime, -) -> bool { - let content_type = if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) { - content_type - } else { - return false; - }; - - let content_type = if let Ok(content_type) = content_type.to_str() { - content_type - } else { - return false; - }; - - content_type.starts_with(expected_content_type.as_ref()) -} diff --git a/axum/src/extract/mod.rs b/axum/src/extract/mod.rs index cf2f1bb2dd..6e17d973ab 100644 --- a/axum/src/extract/mod.rs +++ b/axum/src/extract/mod.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../docs/extract.md")] +use http::header; use rejection::*; pub mod connect_info; @@ -11,13 +12,10 @@ pub mod rejection; pub mod ws; mod content_length_limit; -mod has_content_type; mod host; mod raw_query; mod request_parts; -pub(crate) use has_content_type::has_content_type; - #[doc(inline)] pub use axum_core::extract::{FromRequest, RequestParts}; @@ -84,6 +82,26 @@ pub(crate) fn take_body(req: &mut RequestParts) -> Result( + req: &RequestParts, + expected_content_type: &mime::Mime, +) -> bool { + let content_type = if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) { + content_type + } else { + return false; + }; + + let content_type = if let Ok(content_type) = content_type.to_str() { + content_type + } else { + return false; + }; + + content_type.starts_with(expected_content_type.as_ref()) +} + #[cfg(test)] mod tests { use crate::{routing::get, test_helpers::*, Router}; From 895df4aac391229c47ad689591071a16210afb0e Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sun, 15 May 2022 16:16:25 +0200 Subject: [PATCH 4/6] Update axum-extra/src/extract/form.rs Co-authored-by: Jonas Platte --- axum-extra/src/extract/form.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/axum-extra/src/extract/form.rs b/axum-extra/src/extract/form.rs index 9797581203..48da89603f 100644 --- a/axum-extra/src/extract/form.rs +++ b/axum-extra/src/extract/form.rs @@ -20,7 +20,7 @@ use std::ops::Deref; /// # Differences from `axum::extract::Form` /// /// This extractor uses [`serde_html_form`] under-the-hood which supports multi-value items. These -/// are sent by HTML checkboxes and multi selects. Those values can be collected into a `Vec`. +/// are sent by multiple `` attributes of the same name (e.g. checkboxes) and `` attributes of the same name (e.g. checkboxes) and `` attributes of the same name (e.g. checkboxes) and `