Add axum_extra::extract::Form (#1031)

* Add `axum_extra::extra::Form`

* update tokio-util ban
This commit is contained in:
David Pedersen 2022-05-15 16:17:45 +01:00 committed by GitHub
parent d37b93a3c4
commit 178e1801e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 161 additions and 13 deletions

View file

@ -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)

View file

@ -17,6 +17,7 @@ typed-routing = ["axum-macros", "serde", "percent-encoding"]
cookie = ["cookie-lib"]
cookie-signed = ["cookie", "cookie-lib/signed"]
cookie-private = ["cookie", "cookie-lib/private"]
form = ["serde", "serde_html_form"]
spa = ["tower-http/fs"]
[dependencies]
@ -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"] }

View file

@ -0,0 +1,136 @@
use axum::{
async_trait,
body::HttpBody,
extract::{
rejection::{FailedToDeserializeQueryString, FormRejection, InvalidFormContentType},
FromRequest, RequestParts,
},
BoxError,
};
use bytes::Bytes;
use http::{header, 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 multiple `<input>` attributes of the same name (e.g. checkboxes) and `<select>`s
/// with the `multiple` attribute. Those values can be collected into a `Vec` or other sequential
/// container.
///
/// # Example
///
/// ```rust,no_run
/// use axum_extra::extract::Form;
/// use serde::Deserialize;
///
/// #[derive(Deserialize)]
/// struct Payload {
/// #[serde(rename = "value")]
/// values: Vec<String>,
/// }
///
/// async fn accept_form(Form(payload): Form<Payload>) {
/// // ...
/// }
/// ```
///
/// [`serde_html_form`]: https://crates.io/crates/serde_html_form
#[derive(Debug, Clone, Copy, Default)]
pub struct Form<T>(pub T);
impl<T> Deref for Form<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[async_trait]
impl<T, B> FromRequest<B> for Form<T>
where
T: DeserializeOwned,
B: HttpBody + Send,
B::Data: Send,
B::Error: Into<BoxError>,
{
type Rejection = FormRejection;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
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::<T, _>)?;
Ok(Form(value))
} else {
if !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::<T, _>)?;
Ok(Form(value))
}
}
}
// this is duplicated in `axum/src/extract/mod.rs`
fn has_content_type<B>(req: &RequestParts<B>, 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 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<String>,
}
let app = Router::new().route(
"/",
post(|Form(data): Form<Data>| 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");
}
}

View file

@ -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;

View file

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`Path<(String, String)>` or `Path<SomeStruct>` ([#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

View file

@ -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::<T, _>)?;
.map_err(FailedToDeserializeQueryString::__private_new::<T, _>)?;
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::<T, _>)?;
.map_err(FailedToDeserializeQueryString::__private_new::<T, _>)?;
Ok(Form(value))
}

View file

@ -78,7 +78,12 @@ pub use self::ws::WebSocketUpgrade;
#[doc(no_inline)]
pub use crate::TypedHeader;
pub(crate) fn has_content_type<B>(
pub(crate) fn take_body<B>(req: &mut RequestParts<B>) -> Result<B, BodyAlreadyExtracted> {
req.take_body().ok_or_else(BodyAlreadyExtracted::default)
}
// this is duplicated in `axum-extra/src/extract/form.rs`
pub(super) fn has_content_type<B>(
req: &RequestParts<B>,
expected_content_type: &mime::Mime,
) -> bool {
@ -97,10 +102,6 @@ pub(crate) fn has_content_type<B>(
content_type.starts_with(expected_content_type.as_ref())
}
pub(crate) fn take_body<B>(req: &mut RequestParts<B>) -> Result<B, BodyAlreadyExtracted> {
req.take_body().ok_or_else(BodyAlreadyExtracted::default)
}
#[cfg(test)]
mod tests {
use crate::{routing::get, test_helpers::*, Router};

View file

@ -59,7 +59,7 @@ where
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let query = req.uri().query().unwrap_or_default();
let value = serde_urlencoded::from_str(query)
.map_err(FailedToDeserializeQueryString::new::<T, _>)?;
.map_err(FailedToDeserializeQueryString::__private_new::<T, _>)?;
Ok(Query(value))
}
}

View file

@ -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<T, E>(error: E) -> Self
#[doc(hidden)]
pub fn __private_new<T, E>(error: E) -> Self
where
E: Into<BoxError>,
{

View file

@ -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]