Merge remote-tracking branch 'origin/main' into david/dont-override-status-codes-of-5xx

This commit is contained in:
Yann Simon
2024-11-30 15:10:01 +01:00
330 changed files with 6760 additions and 2682 deletions
+2 -7
View File
@@ -7,7 +7,6 @@
//! use axum::{
//! body::Bytes,
//! Router,
//! async_trait,
//! routing::get,
//! extract::FromRequestParts,
//! };
@@ -15,7 +14,6 @@
//! // extractors for checking permissions
//! struct AdminPermissions {}
//!
//! #[async_trait]
//! impl<S> FromRequestParts<S> for AdminPermissions
//! where
//! S: Send + Sync,
@@ -29,7 +27,6 @@
//!
//! struct User {}
//!
//! #[async_trait]
//! impl<S> FromRequestParts<S> for User
//! where
//! S: Send + Sync,
@@ -96,7 +93,6 @@
use std::task::{Context, Poll};
use axum::{
async_trait,
extract::FromRequestParts,
response::{IntoResponse, Response},
};
@@ -236,7 +232,6 @@ macro_rules! impl_traits_for_either {
[$($ident:ident),* $(,)?],
$last:ident $(,)?
) => {
#[async_trait]
impl<S, $($ident),*, $last> FromRequestParts<S> for $either<$($ident),*, $last>
where
$($ident: FromRequestParts<S>),*,
@@ -247,12 +242,12 @@ macro_rules! impl_traits_for_either {
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
$(
if let Ok(value) = FromRequestParts::from_request_parts(parts, state).await {
if let Ok(value) = <$ident as FromRequestParts<S>>::from_request_parts(parts, state).await {
return Ok(Self::$ident(value));
}
)*
FromRequestParts::from_request_parts(parts, state).await.map(Self::$last)
<$last as FromRequestParts<S>>::from_request_parts(parts, state).await.map(Self::$last)
}
}
+2 -11
View File
@@ -1,7 +1,4 @@
use axum::{
async_trait,
extract::{Extension, FromRequestParts},
};
use axum::extract::{Extension, FromRequestParts};
use http::request::Parts;
/// Cache results of other extractors.
@@ -19,7 +16,6 @@ use http::request::Parts;
/// ```rust
/// use axum_extra::extract::Cached;
/// use axum::{
/// async_trait,
/// extract::FromRequestParts,
/// response::{IntoResponse, Response},
/// http::{StatusCode, request::Parts},
@@ -28,7 +24,6 @@ use http::request::Parts;
/// #[derive(Clone)]
/// struct Session { /* ... */ }
///
/// #[async_trait]
/// impl<S> FromRequestParts<S> for Session
/// where
/// S: Send + Sync,
@@ -43,7 +38,6 @@ use http::request::Parts;
///
/// struct CurrentUser { /* ... */ }
///
/// #[async_trait]
/// impl<S> FromRequestParts<S> for CurrentUser
/// where
/// S: Send + Sync,
@@ -86,7 +80,6 @@ pub struct Cached<T>(pub T);
#[derive(Clone)]
struct CachedEntry<T>(T);
#[async_trait]
impl<S, T> FromRequestParts<S> for Cached<T>
where
S: Send + Sync,
@@ -111,8 +104,7 @@ axum_core::__impl_deref!(Cached);
#[cfg(test)]
mod tests {
use super::*;
use axum::{extract::FromRequestParts, http::Request, routing::get, Router};
use http::request::Parts;
use axum::{http::Request, routing::get, Router};
use std::{
convert::Infallible,
sync::atomic::{AtomicU32, Ordering},
@@ -126,7 +118,6 @@ mod tests {
#[derive(Clone, Debug, PartialEq, Eq)]
struct Extractor(Instant);
#[async_trait]
impl<S> FromRequestParts<S> for Extractor
where
S: Send + Sync,
-2
View File
@@ -3,7 +3,6 @@
//! See [`CookieJar`], [`SignedCookieJar`], and [`PrivateCookieJar`] for more details.
use axum::{
async_trait,
extract::FromRequestParts,
response::{IntoResponse, IntoResponseParts, Response, ResponseParts},
};
@@ -90,7 +89,6 @@ pub struct CookieJar {
jar: cookie::CookieJar,
}
#[async_trait]
impl<S> FromRequestParts<S> for CookieJar
where
S: Send + Sync,
+3 -5
View File
@@ -1,6 +1,5 @@
use super::{cookies_from_request, set_cookies, Cookie, Key};
use axum::{
async_trait,
extract::{FromRef, FromRequestParts},
response::{IntoResponse, IntoResponseParts, Response, ResponseParts},
};
@@ -49,11 +48,11 @@ use std::{convert::Infallible, fmt, marker::PhantomData};
/// // our application state
/// #[derive(Clone)]
/// struct AppState {
/// // that holds the key used to sign cookies
/// // that holds the key used to encrypt cookies
/// key: Key,
/// }
///
/// // this impl tells `SignedCookieJar` how to access the key from our state
/// // this impl tells `PrivateCookieJar` how to access the key from our state
/// impl FromRef<AppState> for Key {
/// fn from_ref(state: &AppState) -> Self {
/// state.key.clone()
@@ -122,7 +121,6 @@ impl<K> fmt::Debug for PrivateCookieJar<K> {
}
}
#[async_trait]
impl<S, K> FromRequestParts<S> for PrivateCookieJar<K>
where
S: Send + Sync,
@@ -291,7 +289,7 @@ struct PrivateCookieJarIter<'a, K> {
iter: cookie::Iter<'a>,
}
impl<'a, K> Iterator for PrivateCookieJarIter<'a, K> {
impl<K> Iterator for PrivateCookieJarIter<'_, K> {
type Item = Cookie<'static>;
fn next(&mut self) -> Option<Self::Item> {
+1 -3
View File
@@ -1,6 +1,5 @@
use super::{cookies_from_request, set_cookies};
use axum::{
async_trait,
extract::{FromRef, FromRequestParts},
response::{IntoResponse, IntoResponseParts, Response, ResponseParts},
};
@@ -139,7 +138,6 @@ impl<K> fmt::Debug for SignedCookieJar<K> {
}
}
#[async_trait]
impl<S, K> FromRequestParts<S> for SignedCookieJar<K>
where
S: Send + Sync,
@@ -309,7 +307,7 @@ struct SignedCookieJarIter<'a, K> {
iter: cookie::Iter<'a>,
}
impl<'a, K> Iterator for SignedCookieJarIter<'a, K> {
impl<K> Iterator for SignedCookieJarIter<'_, K> {
type Item = Cookie<'static>;
fn next(&mut self) -> Option<Self::Item> {
+11 -9
View File
@@ -1,5 +1,4 @@
use axum::{
async_trait,
extract::{rejection::RawFormRejection, FromRequest, RawForm, Request},
response::{IntoResponse, Response},
Error, RequestExt,
@@ -44,7 +43,6 @@ pub struct Form<T>(pub T);
axum_core::__impl_deref!(Form);
#[async_trait]
impl<T, S> FromRequest<S> for Form<T>
where
T: DeserializeOwned,
@@ -81,11 +79,16 @@ impl IntoResponse for FormRejection {
fn into_response(self) -> Response {
match self {
Self::RawFormRejection(inner) => inner.into_response(),
Self::FailedToDeserializeForm(inner) => (
StatusCode::BAD_REQUEST,
format!("Failed to deserialize form: {inner}"),
)
.into_response(),
Self::FailedToDeserializeForm(inner) => {
let body = format!("Failed to deserialize form: {inner}");
let status = StatusCode::BAD_REQUEST;
axum_core::__log_rejection!(
rejection_type = Self,
body_text = body,
status = status,
);
(status, body).into_response()
}
}
}
}
@@ -113,7 +116,7 @@ mod tests {
use super::*;
use crate::test_helpers::*;
use axum::{routing::post, Router};
use http::{header::CONTENT_TYPE, StatusCode};
use http::header::CONTENT_TYPE;
use serde::Deserialize;
#[tokio::test]
@@ -135,7 +138,6 @@ mod tests {
.post("/")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body("value=one&value=two")
.send()
.await;
assert_eq!(res.status(), StatusCode::OK);
+201
View File
@@ -0,0 +1,201 @@
use super::rejection::{FailedToResolveHost, HostRejection};
use axum::extract::FromRequestParts;
use http::{
header::{HeaderMap, FORWARDED},
request::Parts,
uri::Authority,
};
const X_FORWARDED_HOST_HEADER_KEY: &str = "X-Forwarded-Host";
/// Extractor that resolves the host of the request.
///
/// Host is resolved through the following, in order:
/// - `Forwarded` header
/// - `X-Forwarded-Host` header
/// - `Host` header
/// - Authority of the request URI
///
/// See <https://www.rfc-editor.org/rfc/rfc9110.html#name-host-and-authority> for the definition of
/// host.
///
/// Note that user agents can set `X-Forwarded-Host` and `Host` headers to arbitrary values so make
/// sure to validate them to avoid security issues.
#[derive(Debug, Clone)]
pub struct Host(pub String);
impl<S> FromRequestParts<S> for Host
where
S: Send + Sync,
{
type Rejection = HostRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(host) = parse_forwarded(&parts.headers) {
return Ok(Host(host.to_owned()));
}
if let Some(host) = parts
.headers
.get(X_FORWARDED_HOST_HEADER_KEY)
.and_then(|host| host.to_str().ok())
{
return Ok(Host(host.to_owned()));
}
if let Some(host) = parts
.headers
.get(http::header::HOST)
.and_then(|host| host.to_str().ok())
{
return Ok(Host(host.to_owned()));
}
if let Some(authority) = parts.uri.authority() {
return Ok(Host(parse_authority(authority).to_owned()));
}
Err(HostRejection::FailedToResolveHost(FailedToResolveHost))
}
}
#[allow(warnings)]
fn parse_forwarded(headers: &HeaderMap) -> Option<&str> {
// if there are multiple `Forwarded` `HeaderMap::get` will return the first one
let forwarded_values = headers.get(FORWARDED)?.to_str().ok()?;
// get the first set of values
let first_value = forwarded_values.split(',').nth(0)?;
// find the value of the `host` field
first_value.split(';').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
key.trim()
.eq_ignore_ascii_case("host")
.then(|| value.trim().trim_matches('"'))
})
}
fn parse_authority(auth: &Authority) -> &str {
auth.as_str()
.rsplit('@')
.next()
.expect("split always has at least 1 item")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::TestClient;
use axum::{routing::get, Router};
use http::{header::HeaderName, Request};
fn test_client() -> TestClient {
async fn host_as_body(Host(host): Host) -> String {
host
}
TestClient::new(Router::new().route("/", get(host_as_body)))
}
#[crate::test]
async fn host_header() {
let original_host = "some-domain:123";
let host = test_client()
.get("/")
.header(http::header::HOST, original_host)
.await
.text()
.await;
assert_eq!(host, original_host);
}
#[crate::test]
async fn x_forwarded_host_header() {
let original_host = "some-domain:456";
let host = test_client()
.get("/")
.header(X_FORWARDED_HOST_HEADER_KEY, original_host)
.await
.text()
.await;
assert_eq!(host, original_host);
}
#[crate::test]
async fn x_forwarded_host_precedence_over_host_header() {
let x_forwarded_host_header = "some-domain:456";
let host_header = "some-domain:123";
let host = test_client()
.get("/")
.header(X_FORWARDED_HOST_HEADER_KEY, x_forwarded_host_header)
.header(http::header::HOST, host_header)
.await
.text()
.await;
assert_eq!(host, x_forwarded_host_header);
}
#[crate::test]
async fn uri_host() {
let client = test_client();
let port = client.server_port();
let host = client.get("/").await.text().await;
assert_eq!(host, format!("127.0.0.1:{port}"));
}
#[crate::test]
async fn ip4_uri_host() {
let mut parts = Request::new(()).into_parts().0;
parts.uri = "https://127.0.0.1:1234/image.jpg".parse().unwrap();
let host = Host::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(host.0, "127.0.0.1:1234");
}
#[crate::test]
async fn ip6_uri_host() {
let mut parts = Request::new(()).into_parts().0;
parts.uri = "http://cool:user@[::1]:456/file.txt".parse().unwrap();
let host = Host::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(host.0, "[::1]:456");
}
#[test]
fn forwarded_parsing() {
// the basic case
let headers = header_map(&[(FORWARDED, "host=192.0.2.60;proto=http;by=203.0.113.43")]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "192.0.2.60");
// is case insensitive
let headers = header_map(&[(FORWARDED, "host=192.0.2.60;proto=http;by=203.0.113.43")]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "192.0.2.60");
// ipv6
let headers = header_map(&[(FORWARDED, "host=\"[2001:db8:cafe::17]:4711\"")]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "[2001:db8:cafe::17]:4711");
// multiple values in one header
let headers = header_map(&[(FORWARDED, "host=192.0.2.60, host=127.0.0.1")]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "192.0.2.60");
// multiple header values
let headers = header_map(&[
(FORWARDED, "host=192.0.2.60"),
(FORWARDED, "host=127.0.0.1"),
]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "192.0.2.60");
}
fn header_map(values: &[(HeaderName, &str)]) -> HeaderMap {
let mut headers = HeaderMap::new();
for (key, value) in values {
headers.append(key, value.parse().unwrap());
}
headers
}
}
+8 -26
View File
@@ -1,4 +1,3 @@
use axum::async_trait;
use axum::extract::{FromRequest, Request};
use axum_core::__composite_rejection as composite_rejection;
use axum_core::__define_rejection as define_rejection;
@@ -10,7 +9,7 @@ use std::marker::PhantomData;
/// JSON Extractor for zero-copy deserialization.
///
/// Deserialize request bodies into some type that implements [`serde::Deserialize<'de>`].
/// Deserialize request bodies into some type that implements [`serde::Deserialize<'de>`][serde::Deserialize].
/// Parsing JSON is delayed until [`deserialize`](JsonDeserializer::deserialize) is called.
/// If the type implements [`serde::de::DeserializeOwned`], the [`Json`](axum::Json) extractor should
/// be preferred.
@@ -23,8 +22,7 @@ use std::marker::PhantomData;
/// Additionally, a `JsonRejection` error will be returned, when calling `deserialize` if:
///
/// - The body doesn't contain syntactically valid JSON.
/// - The body contains syntactically valid JSON, but it couldn't be deserialized into the target
/// type.
/// - The body contains syntactically valid JSON, but it couldn't be deserialized into the target type.
/// - Attempting to deserialize escaped JSON into a type that must be borrowed (e.g. `&'a str`).
///
/// ⚠️ `serde` will implicitly try to borrow for `&str` and `&[u8]` types, but will error if the
@@ -85,7 +83,6 @@ pub struct JsonDeserializer<T> {
_marker: PhantomData<T>,
}
#[async_trait]
impl<T, S> FromRequest<S> for JsonDeserializer<T>
where
T: Deserialize<'static>,
@@ -205,7 +202,7 @@ fn json_content_type(headers: &HeaderMap) -> bool {
};
let is_json_content_type = mime.type_() == "application"
&& (mime.subtype() == "json" || mime.suffix().map_or(false, |name| name == "json"));
&& (mime.subtype() == "json" || mime.suffix().is_some_and(|name| name == "json"));
is_json_content_type
}
@@ -245,7 +242,7 @@ mod tests {
let app = Router::new().route("/", post(handler));
let client = TestClient::new(app);
let res = client.post("/").json(&json!({ "foo": "bar" })).send().await;
let res = client.post("/").json(&json!({ "foo": "bar" })).await;
let body = res.text().await;
assert_eq!(body, "bar");
@@ -277,11 +274,7 @@ mod tests {
let client = TestClient::new(app);
// The escaped characters prevent serde_json from borrowing.
let res = client
.post("/")
.json(&json!({ "foo": "\"bar\"" }))
.send()
.await;
let res = client.post("/").json(&json!({ "foo": "\"bar\"" })).await;
let body = res.text().await;
@@ -308,19 +301,11 @@ mod tests {
let client = TestClient::new(app);
let res = client
.post("/")
.json(&json!({ "foo": "good" }))
.send()
.await;
let res = client.post("/").json(&json!({ "foo": "good" })).await;
let body = res.text().await;
assert_eq!(body, "good");
let res = client
.post("/")
.json(&json!({ "foo": "\"bad\"" }))
.send()
.await;
let res = client.post("/").json(&json!({ "foo": "\"bad\"" })).await;
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body_text = res.text().await;
assert_eq!(
@@ -344,7 +329,7 @@ mod tests {
let app = Router::new().route("/", post(handler));
let client = TestClient::new(app);
let res = client.post("/").body(r#"{ "foo": "bar" }"#).send().await;
let res = client.post("/").body(r#"{ "foo": "bar" }"#).await;
let status = res.status();
@@ -366,7 +351,6 @@ mod tests {
.post("/")
.header("content-type", content_type)
.body("{}")
.send()
.await;
res.status() == StatusCode::OK
@@ -395,7 +379,6 @@ mod tests {
.post("/")
.body("{")
.header("content-type", "application/json")
.send()
.await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
@@ -433,7 +416,6 @@ mod tests {
.post("/")
.body("{\"a\": 1, \"b\": [{\"x\": 2}]}")
.header("content-type", "application/json")
.send()
.await;
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
+12 -1
View File
@@ -1,7 +1,9 @@
//! Additional extractors.
mod cached;
mod host;
mod optional_path;
pub mod rejection;
mod with_rejection;
#[cfg(feature = "form")]
@@ -19,7 +21,12 @@ mod query;
#[cfg(feature = "multipart")]
pub mod multipart;
pub use self::{cached::Cached, optional_path::OptionalPath, with_rejection::WithRejection};
#[cfg(feature = "scheme")]
mod scheme;
pub use self::{
cached::Cached, host::Host, optional_path::OptionalPath, with_rejection::WithRejection,
};
#[cfg(feature = "cookie")]
pub use self::cookie::CookieJar;
@@ -39,6 +46,10 @@ pub use self::query::{OptionalQuery, OptionalQueryRejection, Query, QueryRejecti
#[cfg(feature = "multipart")]
pub use self::multipart::Multipart;
#[cfg(feature = "scheme")]
#[doc(no_inline)]
pub use self::scheme::{Scheme, SchemeMissing};
#[cfg(feature = "json-deserializer")]
pub use self::json_deserializer::{
JsonDataError, JsonDeserializer, JsonDeserializerRejection, JsonSyntaxError,
+11 -7
View File
@@ -3,7 +3,6 @@
//! See [`Multipart`] for more details.
use axum::{
async_trait,
body::{Body, Bytes},
extract::FromRequest,
response::{IntoResponse, Response},
@@ -75,7 +74,7 @@ use std::{
/// to keep `Field`s around from previous loop iterations. That will minimize the risk of runtime
/// errors.
///
/// # Differences between this and `axum::extract::Multipart`
/// # Differences between this and `axum::extract::Multipart`
///
/// `axum::extract::Multipart` uses lifetimes to enforce field exclusivity at compile time, however
/// that leads to significant usability issues such as `Field` not being `'static`.
@@ -90,7 +89,6 @@ pub struct Multipart {
inner: multer::Multipart<'static>,
}
#[async_trait]
impl<S> FromRequest<S> for Multipart
where
S: Send + Sync,
@@ -379,7 +377,13 @@ pub struct InvalidBoundary;
impl IntoResponse for InvalidBoundary {
fn into_response(self) -> Response {
(self.status(), self.body_text()).into_response()
let body = self.body_text();
axum_core::__log_rejection!(
rejection_type = Self,
body_text = body,
status = self.status(),
);
(self.status(), body).into_response()
}
}
@@ -407,7 +411,7 @@ impl std::error::Error for InvalidBoundary {}
mod tests {
use super::*;
use crate::test_helpers::*;
use axum::{extract::DefaultBodyLimit, response::IntoResponse, routing::post, Router};
use axum::{extract::DefaultBodyLimit, routing::post, Router};
#[tokio::test]
async fn content_type_with_encoding() {
@@ -437,7 +441,7 @@ mod tests {
.unwrap(),
);
client.post("/").multipart(form).send().await;
client.post("/").multipart(form).await;
}
// No need for this to be a #[test], we just want to make sure it compiles
@@ -466,7 +470,7 @@ mod tests {
let form =
reqwest::multipart::Form::new().part("file", reqwest::multipart::Part::bytes(BYTES));
let res = client.post("/").multipart(form).send().await;
let res = client.post("/").multipart(form).await;
assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
}
+7 -9
View File
@@ -1,5 +1,4 @@
use axum::{
async_trait,
extract::{path::ErrorKind, rejection::PathRejection, FromRequestParts, Path},
RequestPartsExt,
};
@@ -29,13 +28,12 @@ use serde::de::DeserializeOwned;
///
/// let app = Router::new()
/// .route("/blog", get(render_blog))
/// .route("/blog/:page", get(render_blog));
/// .route("/blog/{page}", get(render_blog));
/// # let app: Router = app;
/// ```
#[derive(Debug)]
pub struct OptionalPath<T>(pub Option<T>);
#[async_trait]
impl<T, S> FromRequestParts<S> for OptionalPath<T>
where
T: DeserializeOwned + Send + 'static,
@@ -77,26 +75,26 @@ mod tests {
let app = Router::new()
.route("/", get(handle))
.route("/:num", get(handle));
.route("/{num}", get(handle));
let client = TestClient::new(app);
let res = client.get("/").send().await;
let res = client.get("/").await;
assert_eq!(res.text().await, "Success: 0");
let res = client.get("/1").send().await;
let res = client.get("/1").await;
assert_eq!(res.text().await, "Success: 1");
let res = client.get("/0").send().await;
let res = client.get("/0").await;
assert_eq!(
res.text().await,
"Invalid URL: invalid value: integer `0`, expected a nonzero u32"
);
let res = client.get("/NaN").send().await;
let res = client.get("/NaN").await;
assert_eq!(
res.text().await,
"Invalid URL: Cannot parse `\"NaN\"` to a `u32`"
"Invalid URL: Cannot parse `NaN` to a `u32`"
);
}
}
+39 -13
View File
@@ -1,5 +1,4 @@
use axum::{
async_trait,
extract::FromRequestParts,
response::{IntoResponse, Response},
Error,
@@ -51,11 +50,37 @@ use std::fmt;
/// example.
///
/// [example]: https://github.com/tokio-rs/axum/blob/main/examples/query-params-with-empty-strings/src/main.rs
///
/// While `Option<T>` will handle empty parameters (e.g. `param=`), beware when using this with a
/// `Vec<T>`. If your list is optional, use `Vec<T>` in combination with `#[serde(default)]`
/// instead of `Option<Vec<T>>`. `Option<Vec<T>>` will handle 0, 2, or more arguments, but not one
/// argument.
///
/// # Example
///
/// ```rust,no_run
/// use axum::{routing::get, Router};
/// use axum_extra::extract::Query;
/// use serde::Deserialize;
///
/// #[derive(Deserialize)]
/// struct Params {
/// #[serde(default)]
/// items: Vec<usize>,
/// }
///
/// // This will parse 0 occurrences of `items` as an empty `Vec`.
/// async fn process_items(Query(params): Query<Params>) {
/// // ...
/// }
///
/// let app = Router::new().route("/process_items", get(process_items));
/// # let _: Router = app;
/// ```
#[cfg_attr(docsrs, doc(cfg(feature = "query")))]
#[derive(Debug, Clone, Copy, Default)]
pub struct Query<T>(pub T);
#[async_trait]
impl<T, S> FromRequestParts<S> for Query<T>
where
T: DeserializeOwned,
@@ -87,11 +112,16 @@ pub enum QueryRejection {
impl IntoResponse for QueryRejection {
fn into_response(self) -> Response {
match self {
Self::FailedToDeserializeQueryString(inner) => (
StatusCode::BAD_REQUEST,
format!("Failed to deserialize query string: {inner}"),
)
.into_response(),
Self::FailedToDeserializeQueryString(inner) => {
let body = format!("Failed to deserialize query string: {inner}");
let status = StatusCode::BAD_REQUEST;
axum_core::__log_rejection!(
rejection_type = Self,
body_text = body,
status = status,
);
(status, body).into_response()
}
}
}
}
@@ -155,7 +185,6 @@ impl std::error::Error for QueryRejection {
#[derive(Debug, Clone, Copy, Default)]
pub struct OptionalQuery<T>(pub Option<T>);
#[async_trait]
impl<T, S> FromRequestParts<S> for OptionalQuery<T>
where
T: DeserializeOwned,
@@ -235,7 +264,7 @@ mod tests {
use super::*;
use crate::test_helpers::*;
use axum::{routing::post, Router};
use http::{header::CONTENT_TYPE, StatusCode};
use http::header::CONTENT_TYPE;
use serde::Deserialize;
#[tokio::test]
@@ -257,7 +286,6 @@ mod tests {
.post("/?value=one&value=two")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body("")
.send()
.await;
assert_eq!(res.status(), StatusCode::OK);
@@ -286,7 +314,6 @@ mod tests {
.post("/?value=one&value=two")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body("")
.send()
.await;
assert_eq!(res.status(), StatusCode::OK);
@@ -312,7 +339,7 @@ mod tests {
let client = TestClient::new(app);
let res = client.post("/").body("").send().await;
let res = client.post("/").body("").await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "None");
@@ -341,7 +368,6 @@ mod tests {
.post("/?other=something")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body("")
.send()
.await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
+23
View File
@@ -0,0 +1,23 @@
//! Rejection response types.
use axum_core::{
__composite_rejection as composite_rejection, __define_rejection as define_rejection,
};
define_rejection! {
#[status = BAD_REQUEST]
#[body = "No host found in request"]
/// Rejection type used if the [`Host`](super::Host) extractor is unable to
/// resolve a host.
pub struct FailedToResolveHost;
}
composite_rejection! {
/// Rejection used for [`Host`](super::Host).
///
/// Contains one variant for each way the [`Host`](super::Host) extractor
/// can fail.
pub enum HostRejection {
FailedToResolveHost,
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Extractor that parses the scheme of a request.
//! See [`Scheme`] for more details.
use axum::{
extract::FromRequestParts,
response::{IntoResponse, Response},
};
use http::{
header::{HeaderMap, FORWARDED},
request::Parts,
};
const X_FORWARDED_PROTO_HEADER_KEY: &str = "X-Forwarded-Proto";
/// Extractor that resolves the scheme / protocol of a request.
///
/// The scheme is resolved through the following, in order:
/// - `Forwarded` header
/// - `X-Forwarded-Proto` header
/// - Request URI (If the request is an HTTP/2 request! e.g. use `--http2(-prior-knowledge)` with cURL)
///
/// Note that user agents can set the `X-Forwarded-Proto` header to arbitrary values so make
/// sure to validate them to avoid security issues.
#[derive(Debug, Clone)]
pub struct Scheme(pub String);
/// Rejection type used if the [`Scheme`] extractor is unable to
/// resolve a scheme.
#[derive(Debug)]
pub struct SchemeMissing;
impl IntoResponse for SchemeMissing {
fn into_response(self) -> Response {
(http::StatusCode::BAD_REQUEST, "No scheme found in request").into_response()
}
}
impl<S> FromRequestParts<S> for Scheme
where
S: Send + Sync,
{
type Rejection = SchemeMissing;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Within Forwarded header
if let Some(scheme) = parse_forwarded(&parts.headers) {
return Ok(Scheme(scheme.to_owned()));
}
// X-Forwarded-Proto
if let Some(scheme) = parts
.headers
.get(X_FORWARDED_PROTO_HEADER_KEY)
.and_then(|scheme| scheme.to_str().ok())
{
return Ok(Scheme(scheme.to_owned()));
}
// From parts of an HTTP/2 request
if let Some(scheme) = parts.uri.scheme_str() {
return Ok(Scheme(scheme.to_owned()));
}
Err(SchemeMissing)
}
}
fn parse_forwarded(headers: &HeaderMap) -> Option<&str> {
// if there are multiple `Forwarded` `HeaderMap::get` will return the first one
let forwarded_values = headers.get(FORWARDED)?.to_str().ok()?;
// get the first set of values
let first_value = forwarded_values.split(',').next()?;
// find the value of the `proto` field
first_value.split(';').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
key.trim()
.eq_ignore_ascii_case("proto")
.then(|| value.trim().trim_matches('"'))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::TestClient;
use axum::{routing::get, Router};
use http::header::HeaderName;
fn test_client() -> TestClient {
async fn scheme_as_body(Scheme(scheme): Scheme) -> String {
scheme
}
TestClient::new(Router::new().route("/", get(scheme_as_body)))
}
#[crate::test]
async fn forwarded_scheme_parsing() {
// the basic case
let headers = header_map(&[(FORWARDED, "host=192.0.2.60;proto=http;by=203.0.113.43")]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "http");
// is case insensitive
let headers = header_map(&[(FORWARDED, "host=192.0.2.60;PROTO=https;by=203.0.113.43")]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "https");
// multiple values in one header
let headers = header_map(&[(FORWARDED, "proto=ftp, proto=https")]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "ftp");
// multiple header values
let headers = header_map(&[(FORWARDED, "proto=ftp"), (FORWARDED, "proto=https")]);
let value = parse_forwarded(&headers).unwrap();
assert_eq!(value, "ftp");
}
#[crate::test]
async fn x_forwarded_scheme_header() {
let original_scheme = "https";
let scheme = test_client()
.get("/")
.header(X_FORWARDED_PROTO_HEADER_KEY, original_scheme)
.await
.text()
.await;
assert_eq!(scheme, original_scheme);
}
#[crate::test]
async fn precedence_forwarded_over_x_forwarded() {
let scheme = test_client()
.get("/")
.header(X_FORWARDED_PROTO_HEADER_KEY, "https")
.header(FORWARDED, "proto=ftp")
.await
.text()
.await;
assert_eq!(scheme, "ftp");
}
fn header_map(values: &[(HeaderName, &str)]) -> HeaderMap {
let mut headers = HeaderMap::new();
for (key, value) in values {
headers.append(key, value.parse().unwrap());
}
headers
}
}
+22 -9
View File
@@ -1,11 +1,13 @@
use axum::async_trait;
use axum::extract::{FromRequest, FromRequestParts, Request};
use axum::response::IntoResponse;
use http::request::Parts;
use std::fmt::Debug;
use std::fmt::{Debug, Display};
use std::marker::PhantomData;
use std::ops::{Deref, DerefMut};
#[cfg(feature = "typed-routing")]
use crate::routing::TypedPath;
/// Extractor for customizing extractor rejections
///
/// `WithRejection` wraps another extractor and gives you the result. If the
@@ -107,7 +109,6 @@ impl<E, R> DerefMut for WithRejection<E, R> {
}
}
#[async_trait]
impl<E, R, S> FromRequest<S> for WithRejection<E, R>
where
S: Send + Sync,
@@ -122,7 +123,6 @@ where
}
}
#[async_trait]
impl<E, R, S> FromRequestParts<S> for WithRejection<E, R>
where
S: Send + Sync,
@@ -137,22 +137,35 @@ where
}
}
#[cfg(feature = "typed-routing")]
impl<E, R> TypedPath for WithRejection<E, R>
where
E: TypedPath,
{
const PATH: &'static str = E::PATH;
}
impl<E, R> Display for WithRejection<E, R>
where
E: Display,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::extract::FromRequestParts;
use axum::http::Request;
use axum::response::Response;
use http::request::Parts;
use super::*;
#[tokio::test]
async fn extractor_rejection_is_transformed() {
struct TestExtractor;
struct TestRejection;
#[async_trait]
impl<S> FromRequestParts<S> for TestExtractor
where
S: Send + Sync,
+2 -5
View File
@@ -47,7 +47,6 @@ pub trait HandlerCallWithExtractors<T, S>: Sized {
/// use axum_extra::handler::HandlerCallWithExtractors;
/// use axum::{
/// Router,
/// async_trait,
/// routing::get,
/// extract::FromRequestParts,
/// };
@@ -68,7 +67,6 @@ pub trait HandlerCallWithExtractors<T, S>: Sized {
/// // extractors for checking permissions
/// struct AdminPermissions {}
///
/// #[async_trait]
/// impl<S> FromRequestParts<S> for AdminPermissions
/// where
/// S: Send + Sync,
@@ -82,7 +80,6 @@ pub trait HandlerCallWithExtractors<T, S>: Sized {
///
/// struct User {}
///
/// #[async_trait]
/// impl<S> FromRequestParts<S> for User
/// where
/// S: Send + Sync,
@@ -95,7 +92,7 @@ pub trait HandlerCallWithExtractors<T, S>: Sized {
/// }
///
/// let app = Router::new().route(
/// "/users/:id",
/// "/users/{id}",
/// get(
/// // first try `admin`, if that rejects run `user`, finally falling back
/// // to `guest`
@@ -168,7 +165,7 @@ pub struct IntoHandler<H, T, S> {
impl<H, T, S> Handler<T, S> for IntoHandler<H, T, S>
where
H: HandlerCallWithExtractors<T, S> + Clone + Send + 'static,
H: HandlerCallWithExtractors<T, S> + Clone + Send + Sync + 'static,
T: FromRequest<S> + Send + 'static,
T::Rejection: Send,
S: Send + Sync + 'static,
+6 -6
View File
@@ -54,8 +54,8 @@ where
impl<S, L, R, Lt, Rt, M> Handler<(M, Lt, Rt), S> for Or<L, R, Lt, Rt, S>
where
L: HandlerCallWithExtractors<Lt, S> + Clone + Send + 'static,
R: HandlerCallWithExtractors<Rt, S> + Clone + Send + 'static,
L: HandlerCallWithExtractors<Lt, S> + Clone + Send + Sync + 'static,
R: HandlerCallWithExtractors<Rt, S> + Clone + Send + Sync + 'static,
Lt: FromRequestParts<S> + Send + 'static,
Rt: FromRequest<S, M> + Send + 'static,
Lt::Rejection: Send,
@@ -134,17 +134,17 @@ mod tests {
"fallback"
}
let app = Router::new().route("/:id", get(one.or(two).or(three)));
let app = Router::new().route("/{id}", get(one.or(two).or(three)));
let client = TestClient::new(app);
let res = client.get("/123").send().await;
let res = client.get("/123").await;
assert_eq!(res.text().await, "123");
let res = client.get("/foo?a=bar").send().await;
let res = client.get("/foo?a=bar").await;
assert_eq!(res.text().await, "bar");
let res = client.get("/foo").send().await;
let res = client.get("/foo").await;
assert_eq!(res.text().await, "fallback");
}
}
+3 -6
View File
@@ -1,7 +1,6 @@
//! Newline delimited JSON extractor and response.
use axum::{
async_trait,
body::Body,
extract::{FromRequest, Request},
response::{IntoResponse, Response},
@@ -55,7 +54,7 @@ pin_project! {
/// JsonLines::new(stream_of_values()).into_response()
/// }
/// ```
// we use `AsExtractor` as the default because you're more likely to name this type if its used
// we use `AsExtractor` as the default because you're more likely to name this type if it's used
// as an extractor
#[must_use]
pub struct JsonLines<S, T = AsExtractor> {
@@ -99,7 +98,6 @@ impl<S> JsonLines<S, AsResponse> {
}
}
#[async_trait]
impl<S, T> FromRequest<S> for JsonLines<T, AsExtractor>
where
T: DeserializeOwned,
@@ -184,7 +182,7 @@ mod tests {
use futures_util::StreamExt;
use http::StatusCode;
use serde::Deserialize;
use std::{convert::Infallible, error::Error};
use std::error::Error;
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
struct User {
@@ -224,7 +222,6 @@ mod tests {
]
.join("\n"),
)
.send()
.await;
assert_eq!(res.status(), StatusCode::OK);
}
@@ -245,7 +242,7 @@ mod tests {
let client = TestClient::new(app);
let res = client.get("/").send().await;
let res = client.get("/").await;
let values = res
.text()
+18 -19
View File
@@ -9,20 +9,22 @@
//!
//! Name | Description | Default?
//! ---|---|---
//! `async-read-body` | Enables the `AsyncReadBody` body | No
//! `cookie` | Enables the `CookieJar` extractor | No
//! `cookie-private` | Enables the `PrivateCookieJar` extractor | No
//! `cookie-signed` | Enables the `SignedCookieJar` extractor | No
//! `cookie-key-expansion` | Enables the `Key::derive_from` method | No
//! `erased-json` | Enables the `ErasedJson` response | No
//! `form` | Enables the `Form` extractor | No
//! `json-deserializer` | Enables the `JsonDeserializer` extractor | No
//! `json-lines` | Enables the `JsonLines` extractor and response | No
//! `multipart` | Enables the `Multipart` extractor | No
//! `protobuf` | Enables the `Protobuf` extractor and response | No
//! `query` | Enables the `Query` extractor | No
//! `typed-routing` | Enables the `TypedPath` routing utilities | No
//! `typed-header` | Enables the `TypedHeader` extractor and response | No
//! `async-read-body` | Enables the [`AsyncReadBody`](crate::body::AsyncReadBody) body | No
//! `attachment` | Enables the [`Attachment`](crate::response::Attachment) response | No
//! `cookie` | Enables the [`CookieJar`](crate::extract::CookieJar) extractor | No
//! `cookie-private` | Enables the [`PrivateCookieJar`](crate::extract::PrivateCookieJar) extractor | No
//! `cookie-signed` | Enables the [`SignedCookieJar`](crate::extract::SignedCookieJar) extractor | No
//! `cookie-key-expansion` | Enables the [`Key::derive_from`](crate::extract::cookie::Key::derive_from) method | No
//! `erased-json` | Enables the [`ErasedJson`](crate::response::ErasedJson) response | No
//! `form` | Enables the [`Form`](crate::extract::Form) extractor | No
//! `json-deserializer` | Enables the [`JsonDeserializer`](crate::extract::JsonDeserializer) extractor | No
//! `json-lines` | Enables the [`JsonLines`](crate::extract::JsonLines) extractor and response | No
//! `multipart` | Enables the [`Multipart`](crate::extract::Multipart) extractor | No
//! `protobuf` | Enables the [`Protobuf`](crate::protobuf::Protobuf) extractor and response | No
//! `query` | Enables the [`Query`](crate::extract::Query) extractor | No
//! `tracing` | Log rejections from built-in extractors | Yes
//! `typed-routing` | Enables the [`TypedPath`](crate::routing::TypedPath) routing utilities | No
//! `typed-header` | Enables the [`TypedHeader`] extractor and response | No
//!
//! [`axum`]: https://crates.io/crates/axum
@@ -39,7 +41,6 @@
clippy::needless_borrow,
clippy::match_wildcard_for_single_variants,
clippy::if_let_mutex,
clippy::mismatched_target_os,
clippy::await_holding_lock,
clippy::match_on_vec_items,
clippy::imprecise_flops,
@@ -96,11 +97,10 @@ pub use typed_header::TypedHeader;
#[cfg(feature = "protobuf")]
pub mod protobuf;
/// _not_ public API
#[cfg(feature = "typed-routing")]
#[doc(hidden)]
pub mod __private {
//! _not_ public API
use percent_encoding::{AsciiSet, CONTROLS};
pub use percent_encoding::utf8_percent_encode;
@@ -115,9 +115,8 @@ pub mod __private {
use axum_macros::__private_axum_test as test;
#[cfg(test)]
#[allow(unused_imports)]
pub(crate) mod test_helpers {
#![allow(unused_imports)]
use axum::{extract::Request, response::Response, serve};
mod test_client {
+4 -7
View File
@@ -1,7 +1,6 @@
//! Protocol Buffer extractor and response.
use axum::{
async_trait,
extract::{rejection::BytesRejection, FromRequest, Request},
response::{IntoResponse, IntoResponseFailed, Response},
};
@@ -82,7 +81,7 @@ use prost::Message;
/// # unimplemented!()
/// }
///
/// let app = Router::new().route("/users/:id", get(get_user));
/// let app = Router::new().route("/users/{id}", get(get_user));
/// # let _: Router = app;
/// ```
#[derive(Debug, Clone, Copy, Default)]
@@ -90,7 +89,6 @@ use prost::Message;
#[must_use]
pub struct Protobuf<T>(pub T);
#[async_trait]
impl<T, S> FromRequest<S> for Protobuf<T>
where
T: Message + Default,
@@ -206,7 +204,6 @@ mod tests {
use super::*;
use crate::test_helpers::*;
use axum::{routing::post, Router};
use http::StatusCode;
#[tokio::test]
async fn decode_body() {
@@ -226,7 +223,7 @@ mod tests {
};
let client = TestClient::new(app);
let res = client.post("/").body(input.encode_to_vec()).send().await;
let res = client.post("/").body(input.encode_to_vec()).await;
let body = res.text().await;
@@ -254,7 +251,7 @@ mod tests {
};
let client = TestClient::new(app);
let res = client.post("/").body(input.encode_to_vec()).send().await;
let res = client.post("/").body(input.encode_to_vec()).await;
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
@@ -289,7 +286,7 @@ mod tests {
};
let client = TestClient::new(app);
let res = client.post("/").body(input.encode_to_vec()).send().await;
let res = client.post("/").body(input.encode_to_vec()).await;
assert_eq!(
res.headers()["content-type"],
+103
View File
@@ -0,0 +1,103 @@
use axum::response::IntoResponse;
use http::{header, HeaderMap, HeaderValue};
use tracing::error;
/// A file attachment response.
///
/// This type will set the `Content-Disposition` header to `attachment`. In response a webbrowser
/// will offer to download the file instead of displaying it directly.
///
/// Use the `filename` and `content_type` methods to set the filename or content-type of the
/// attachment. If these values are not set they will not be sent.
///
///
/// # Example
///
/// ```rust
/// use axum::{http::StatusCode, routing::get, Router};
/// use axum_extra::response::Attachment;
///
/// async fn cargo_toml() -> Result<Attachment<String>, (StatusCode, String)> {
/// let file_contents = tokio::fs::read_to_string("Cargo.toml")
/// .await
/// .map_err(|err| (StatusCode::NOT_FOUND, format!("File not found: {err}")))?;
/// Ok(Attachment::new(file_contents)
/// .filename("Cargo.toml")
/// .content_type("text/x-toml"))
/// }
///
/// let app = Router::new().route("/Cargo.toml", get(cargo_toml));
/// let _: Router = app;
/// ```
///
/// # Note
///
/// If you use axum with hyper, hyper will set the `Content-Length` if it is known.
#[derive(Debug)]
#[must_use]
pub struct Attachment<T> {
inner: T,
filename: Option<HeaderValue>,
content_type: Option<HeaderValue>,
}
impl<T: IntoResponse> Attachment<T> {
/// Creates a new [`Attachment`].
pub fn new(inner: T) -> Self {
Self {
inner,
filename: None,
content_type: None,
}
}
/// Sets the filename of the [`Attachment`].
///
/// This updates the `Content-Disposition` header to add a filename.
pub fn filename<H: TryInto<HeaderValue>>(mut self, value: H) -> Self {
self.filename = if let Ok(filename) = value.try_into() {
Some(filename)
} else {
error!("Attachment filename contains invalid characters");
None
};
self
}
/// Sets the content-type of the [`Attachment`]
pub fn content_type<H: TryInto<HeaderValue>>(mut self, value: H) -> Self {
if let Ok(content_type) = value.try_into() {
self.content_type = Some(content_type);
} else {
error!("Attachment content-type contains invalid characters");
}
self
}
}
impl<T> IntoResponse for Attachment<T>
where
T: IntoResponse,
{
fn into_response(self) -> axum::response::Response {
let mut headers = HeaderMap::new();
if let Some(content_type) = self.content_type {
headers.append(header::CONTENT_TYPE, content_type);
}
let content_disposition = if let Some(filename) = self.filename {
let mut bytes = b"attachment; filename=\"".to_vec();
bytes.extend_from_slice(filename.as_bytes());
bytes.push(b'\"');
HeaderValue::from_bytes(&bytes).expect("This was a HeaderValue so this can not fail")
} else {
HeaderValue::from_static("attachment")
};
headers.append(header::CONTENT_DISPOSITION, content_disposition);
(headers, self.inner).into_response()
}
}
+71
View File
@@ -12,6 +12,15 @@ use serde::Serialize;
/// This allows returning a borrowing type from a handler, or returning different response
/// types as JSON from different branches inside a handler.
///
/// Like [`axum::Json`],
/// if the [`Serialize`] implementation fails
/// or if a map with non-string keys is used,
/// a 500 response will be issued
/// whose body is the error message in UTF-8.
///
/// This can be constructed using [`new`](ErasedJson::new)
/// or the [`json!`](crate::json) macro.
///
/// # Example
///
/// ```rust
@@ -77,3 +86,65 @@ impl IntoResponse for ErasedJson {
}
}
}
/// Construct an [`ErasedJson`] response from a JSON literal.
///
/// A `Content-Type: application/json` header is automatically added.
/// Any variable or expression implementing [`Serialize`]
/// can be interpolated as a value in the literal.
/// If the [`Serialize`] implementation fails,
/// or if a map with non-string keys is used,
/// a 500 response will be issued
/// whose body is the error message in UTF-8.
///
/// Internally,
/// this function uses the [`typed_json::json!`] macro,
/// allowing it to perform far fewer allocations
/// than a dynamic macro like [`serde_json::json!`] would
/// it's equivalent to if you had just written
/// `derive(Serialize)` on a struct.
///
/// # Examples
///
/// ```
/// use axum::{
/// Router,
/// extract::Path,
/// response::Response,
/// routing::get,
/// };
/// use axum_extra::response::ErasedJson;
///
/// async fn get_user(Path(user_id) : Path<u64>) -> ErasedJson {
/// let user_name = find_user_name(user_id).await;
/// axum_extra::json!({ "name": user_name })
/// }
///
/// async fn find_user_name(user_id: u64) -> String {
/// // ...
/// # unimplemented!()
/// }
///
/// let app = Router::new().route("/users/{id}", get(get_user));
/// # let _: Router = app;
/// ```
///
/// Trailing commas are allowed in both arrays and objects.
///
/// ```
/// let response = axum_extra::json!(["trailing",]);
/// ```
#[macro_export]
macro_rules! json {
($($t:tt)*) => {
$crate::response::ErasedJson::new(
$crate::response::__private_erased_json::typed_json::json!($($t)*)
)
}
}
/// Not public API. Re-exported as `crate::response::__private_erased_json`.
#[doc(hidden)]
pub mod private {
pub use typed_json;
}
+51
View File
@@ -0,0 +1,51 @@
use axum_core::response::{IntoResponse, Response};
use http::StatusCode;
use std::error::Error;
use tracing::error;
/// Convenience response to create an error response from a non-[`IntoResponse`] error
///
/// This provides a method to quickly respond with an error that does not implement
/// the `IntoResponse` trait itself. This type should only be used for debugging purposes or internal
/// facing applications, as it includes the full error chain with descriptions,
/// thus leaking information that could possibly be sensitive.
///
/// ```rust
/// use axum_extra::response::InternalServerError;
/// use axum_core::response::IntoResponse;
/// # use std::io::{Error, ErrorKind};
/// # fn try_thing() -> Result<(), Error> {
/// # Err(Error::new(ErrorKind::Other, "error"))
/// # }
///
/// async fn maybe_error() -> Result<String, InternalServerError<Error>> {
/// try_thing().map_err(InternalServerError)?;
/// // do something on success
/// # Ok(String::from("ok"))
/// }
/// ```
#[derive(Debug)]
pub struct InternalServerError<T>(pub T);
impl<T: Error + 'static> IntoResponse for InternalServerError<T> {
fn into_response(self) -> Response {
error!(error = &self.0 as &dyn Error);
(
StatusCode::INTERNAL_SERVER_ERROR,
"An error occurred while processing your request.",
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Error, ErrorKind};
#[test]
fn internal_server_error() {
let response = InternalServerError(Error::new(ErrorKind::Other, "Test")).into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}
+20 -8
View File
@@ -3,13 +3,33 @@
#[cfg(feature = "erased-json")]
mod erased_json;
#[cfg(feature = "attachment")]
mod attachment;
#[cfg(feature = "multipart")]
pub mod multiple;
#[cfg(feature = "error_response")]
mod error_response;
#[cfg(feature = "error_response")]
pub use error_response::InternalServerError;
#[cfg(feature = "erased-json")]
pub use erased_json::ErasedJson;
/// _not_ public API
#[cfg(feature = "erased-json")]
#[doc(hidden)]
pub use erased_json::private as __private_erased_json;
#[cfg(feature = "json-lines")]
#[doc(no_inline)]
pub use crate::json_lines::JsonLines;
#[cfg(feature = "attachment")]
pub use attachment::Attachment;
macro_rules! mime_response {
(
$(#[$m:meta])*
@@ -57,14 +77,6 @@ macro_rules! mime_response {
};
}
mime_response! {
/// A HTML response.
///
/// Will automatically get `Content-Type: text/html; charset=utf-8`.
Html,
TEXT_HTML_UTF_8,
}
mime_response! {
/// A JavaScript response.
///
+295
View File
@@ -0,0 +1,295 @@
//! Generate forms to use in responses.
use axum::response::{IntoResponse, Response};
use fastrand;
use http::{header, HeaderMap, StatusCode};
use mime::Mime;
/// Create multipart forms to be used in API responses.
///
/// This struct implements [`IntoResponse`], and so it can be returned from a handler.
#[derive(Debug)]
pub struct MultipartForm {
parts: Vec<Part>,
}
impl MultipartForm {
/// Initialize a new multipart form with the provided vector of parts.
///
/// # Examples
///
/// ```rust
/// use axum_extra::response::multiple::{MultipartForm, Part};
///
/// let parts: Vec<Part> = vec![Part::text("foo".to_string(), "abc"), Part::text("bar".to_string(), "def")];
/// let form = MultipartForm::with_parts(parts);
/// ```
pub fn with_parts(parts: Vec<Part>) -> Self {
MultipartForm { parts }
}
}
impl IntoResponse for MultipartForm {
fn into_response(self) -> Response {
// see RFC5758 for details
let boundary = generate_boundary();
let mut headers = HeaderMap::new();
let mime_type: Mime = match format!("multipart/form-data; boundary={}", boundary).parse() {
Ok(m) => m,
// Realistically this should never happen unless the boundary generation code
// is modified, and that will be caught by unit tests
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid multipart boundary generated",
)
.into_response()
}
};
// The use of unwrap is safe here because mime types are inherently string representable
headers.insert(header::CONTENT_TYPE, mime_type.to_string().parse().unwrap());
let mut serialized_form: Vec<u8> = Vec::new();
for part in self.parts {
// for each part, the boundary is preceded by two dashes
serialized_form.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
serialized_form.extend_from_slice(&part.serialize());
}
serialized_form.extend_from_slice(format!("--{}--", boundary).as_bytes());
(headers, serialized_form).into_response()
}
}
// Valid settings for that header are: "base64", "quoted-printable", "8bit", "7bit", and "binary".
/// A single part of a multipart form as defined by
/// <https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4>
/// and RFC5758.
#[derive(Debug)]
pub struct Part {
// Every part is expected to contain:
// - a [Content-Disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
// header, where `Content-Disposition` is set to `form-data`, with a parameter of `name` that is set to
// the name of the field in the form. In the below example, the name of the field is `user`:
// ```
// Content-Disposition: form-data; name="user"
// ```
// If the field contains a file, then the `filename` parameter may be set to the name of the file.
// Handling for non-ascii field names is not done here, support for non-ascii characters may be encoded using
// methodology described in RFC 2047.
// - (optionally) a `Content-Type` header, which if not set, defaults to `text/plain`.
// If the field contains a file, then the file should be identified with that file's MIME type (eg: `image/gif`).
// If the `MIME` type is not known or specified, then the MIME type should be set to `application/octet-stream`.
/// The name of the part in question
name: String,
/// If the part should be treated as a file, the filename that should be attached that part
filename: Option<String>,
/// The `Content-Type` header. While not strictly required, it is always set here
mime_type: Mime,
/// The content/body of the part
contents: Vec<u8>,
}
impl Part {
/// Create a new part with `Content-Type` of `text/plain` with the supplied name and contents.
///
/// This form will not have a defined file name.
///
/// # Examples
///
/// ```rust
/// use axum_extra::response::multiple::{MultipartForm, Part};
///
/// // create a form with a single part that has a field with a name of "foo",
/// // and a value of "abc"
/// let parts: Vec<Part> = vec![Part::text("foo".to_string(), "abc")];
/// let form = MultipartForm::from_iter(parts);
/// ```
pub fn text(name: String, contents: &str) -> Self {
Self {
name,
filename: None,
mime_type: mime::TEXT_PLAIN_UTF_8,
contents: contents.as_bytes().to_vec(),
}
}
/// Create a new part containing a generic file, with a `Content-Type` of `application/octet-stream`
/// using the provided file name, field name, and contents.
///
/// If the MIME type of the file is known, consider using `Part::raw_part`.
///
/// # Examples
///
/// ```rust
/// use axum_extra::response::multiple::{MultipartForm, Part};
///
/// // create a form with a single part that has a field with a name of "foo",
/// // with a file name of "foo.txt", and with the specified contents
/// let parts: Vec<Part> = vec![Part::file("foo", "foo.txt", vec![0x68, 0x68, 0x20, 0x6d, 0x6f, 0x6d])];
/// let form = MultipartForm::from_iter(parts);
/// ```
pub fn file(field_name: &str, file_name: &str, contents: Vec<u8>) -> Self {
Self {
name: field_name.to_owned(),
filename: Some(file_name.to_owned()),
// If the `MIME` type is not known or specified, then the MIME type should be set to `application/octet-stream`.
// See RFC2388 section 3 for specifics.
mime_type: mime::APPLICATION_OCTET_STREAM,
contents,
}
}
/// Create a new part with more fine-grained control over the semantics of that part.
///
/// The caller is assumed to have set a valid MIME type.
///
/// This function will return an error if the provided MIME type is not valid.
///
/// # Examples
///
/// ```rust
/// use axum_extra::response::multiple::{MultipartForm, Part};
///
/// // create a form with a single part that has a field with a name of "part_name",
/// // with a MIME type of "application/json", and the supplied contents.
/// let parts: Vec<Part> = vec![Part::raw_part("part_name", "application/json", vec![0x68, 0x68, 0x20, 0x6d, 0x6f, 0x6d], None).expect("MIME type must be valid")];
/// let form = MultipartForm::from_iter(parts);
/// ```
pub fn raw_part(
name: &str,
mime_type: &str,
contents: Vec<u8>,
filename: Option<&str>,
) -> Result<Self, &'static str> {
let mime_type = mime_type.parse().map_err(|_| "Invalid MIME type")?;
Ok(Self {
name: name.to_owned(),
filename: filename.map(|f| f.to_owned()),
mime_type,
contents,
})
}
/// Serialize this part into a chunk that can be easily inserted into a larger form
pub(super) fn serialize(&self) -> Vec<u8> {
// A part is serialized in this general format:
// // the filename is optional
// Content-Disposition: form-data; name="FIELD_NAME"; filename="FILENAME"\r\n
// // the mime type (not strictly required by the spec, but always sent here)
// Content-Type: mime/type\r\n
// // a blank line, then the contents of the file start
// \r\n
// CONTENTS\r\n
// Format what we can as a string, then handle the rest at a byte level
let mut serialized_part = format!("Content-Disposition: form-data; name=\"{}\"", self.name);
// specify a filename if one was set
if let Some(filename) = &self.filename {
serialized_part += &format!("; filename=\"{}\"", filename);
}
serialized_part += "\r\n";
// specify the MIME type
serialized_part += &format!("Content-Type: {}\r\n", self.mime_type);
serialized_part += "\r\n";
let mut part_bytes = serialized_part.as_bytes().to_vec();
part_bytes.extend_from_slice(&self.contents);
part_bytes.extend_from_slice(b"\r\n");
part_bytes
}
}
impl FromIterator<Part> for MultipartForm {
fn from_iter<T: IntoIterator<Item = Part>>(iter: T) -> Self {
Self {
parts: iter.into_iter().collect(),
}
}
}
/// A boundary is defined as a user defined (arbitrary) value that does not occur in any of the data.
///
/// Because the specification does not clearly define a methodology for generating boundaries, this implementation
/// follow's Reqwest's, and generates a boundary in the format of `XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX` where `XXXXXXXX`
/// is a hexadecimal representation of a pseudo randomly generated u64.
fn generate_boundary() -> String {
let a = fastrand::u64(0..u64::MAX);
let b = fastrand::u64(0..u64::MAX);
let c = fastrand::u64(0..u64::MAX);
let d = fastrand::u64(0..u64::MAX);
format!("{a:016x}-{b:016x}-{c:016x}-{d:016x}")
}
#[cfg(test)]
mod tests {
use super::{generate_boundary, MultipartForm, Part};
use axum::{body::Body, http};
use axum::{routing::get, Router};
use http::{Request, Response};
use http_body_util::BodyExt;
use mime::Mime;
use tower::ServiceExt;
#[tokio::test]
async fn process_form() -> Result<(), Box<dyn std::error::Error>> {
// create a boilerplate handle that returns a form
async fn handle() -> MultipartForm {
let parts: Vec<Part> = vec![
Part::text("part1".to_owned(), "basictext"),
Part::file(
"part2",
"file.txt",
vec![0x68, 0x69, 0x20, 0x6d, 0x6f, 0x6d],
),
Part::raw_part("part3", "text/plain", b"rawpart".to_vec(), None).unwrap(),
];
MultipartForm::from_iter(parts)
}
// make a request to that handle
let app = Router::new().route("/", get(handle));
let response: Response<_> = app
.oneshot(Request::builder().uri("/").body(Body::empty())?)
.await?;
// content_type header
let ct_header = response.headers().get("content-type").unwrap().to_str()?;
let boundary = ct_header.split("boundary=").nth(1).unwrap().to_owned();
let body: &[u8] = &response.into_body().collect().await?.to_bytes();
assert_eq!(
std::str::from_utf8(body)?,
&format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"part1\"\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
basictext\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"part2\"; filename=\"file.txt\"\r\n\
Content-Type: application/octet-stream\r\n\
\r\n\
hi mom\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"part3\"\r\n\
Content-Type: text/plain\r\n\
\r\n\
rawpart\r\n\
--{boundary}--",
boundary = boundary
)
);
Ok(())
}
#[test]
fn valid_boundary_generation() {
for _ in 0..256 {
let boundary = generate_boundary();
let mime_type: Result<Mime, _> =
format!("multipart/form-data; boundary={}", boundary).parse();
assert!(
mime_type.is_ok(),
"The generated boundary was unable to be parsed into a valid mime type."
);
}
}
}
+55 -16
View File
@@ -1,7 +1,7 @@
//! Additional types for defining routes.
use axum::{
extract::Request,
extract::{OriginalUri, Request},
response::{IntoResponse, Redirect, Response},
routing::{any, MethodRouter},
Router,
@@ -131,6 +131,19 @@ pub trait RouterExt<S>: sealed::Sealed {
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add a typed `CONNECT` route to the router.
///
/// The path will be inferred from the first argument to the handler function which must
/// implement [`TypedPath`].
///
/// See [`TypedPath`] for more details and examples.
#[cfg(feature = "typed-routing")]
fn typed_connect<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S>,
T: SecondElementIs<P> + 'static,
P: TypedPath;
/// Add another route to the router with an additional "trailing slash redirect" route.
///
/// If you add a route _without_ a trailing slash, such as `/foo`, this method will also add a
@@ -165,7 +178,7 @@ pub trait RouterExt<S>: sealed::Sealed {
/// This works like [`RouterExt::route_with_tsr`] but accepts any [`Service`].
fn route_service_with_tsr<T>(self, path: &str, service: T) -> Self
where
T: Service<Request, Error = Infallible> + Clone + Send + 'static,
T: Service<Request, Error = Infallible> + Clone + Send + Sync + 'static,
T::Response: IntoResponse,
T::Future: Send + 'static,
Self: Sized;
@@ -255,6 +268,16 @@ where
self.route(P::PATH, axum::routing::trace(handler))
}
#[cfg(feature = "typed-routing")]
fn typed_connect<H, T, P>(self, handler: H) -> Self
where
H: axum::handler::Handler<T, S>,
T: SecondElementIs<P> + 'static,
P: TypedPath,
{
self.route(P::PATH, axum::routing::connect(handler))
}
#[track_caller]
fn route_with_tsr(mut self, path: &str, method_router: MethodRouter<S>) -> Self
where
@@ -268,7 +291,7 @@ where
#[track_caller]
fn route_service_with_tsr<T>(mut self, path: &str, service: T) -> Self
where
T: Service<Request, Error = Infallible> + Clone + Send + 'static,
T: Service<Request, Error = Infallible> + Clone + Send + Sync + 'static,
T::Response: IntoResponse,
T::Future: Send + 'static,
Self: Sized,
@@ -290,7 +313,7 @@ fn add_tsr_redirect_route<S>(router: Router<S>, path: &str) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
async fn redirect_handler(uri: Uri) -> Response {
async fn redirect_handler(OriginalUri(uri): OriginalUri) -> Response {
let new_uri = map_path(uri, |path| {
path.strip_suffix('/')
.map(Cow::Borrowed)
@@ -342,7 +365,7 @@ mod sealed {
mod tests {
use super::*;
use crate::test_helpers::*;
use axum::{extract::Path, http::StatusCode, routing::get};
use axum::{extract::Path, routing::get};
#[tokio::test]
async fn test_tsr() {
@@ -352,17 +375,17 @@ mod tests {
let client = TestClient::new(app);
let res = client.get("/foo").send().await;
let res = client.get("/foo").await;
assert_eq!(res.status(), StatusCode::OK);
let res = client.get("/foo/").send().await;
let res = client.get("/foo/").await;
assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(res.headers()["location"], "/foo");
let res = client.get("/bar/").send().await;
let res = client.get("/bar/").await;
assert_eq!(res.status(), StatusCode::OK);
let res = client.get("/bar").send().await;
let res = client.get("/bar").await;
assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(res.headers()["location"], "/bar/");
}
@@ -371,29 +394,29 @@ mod tests {
async fn tsr_with_params() {
let app = Router::new()
.route_with_tsr(
"/a/:a",
"/a/{a}",
get(|Path(param): Path<String>| async move { param }),
)
.route_with_tsr(
"/b/:b/",
"/b/{b}/",
get(|Path(param): Path<String>| async move { param }),
);
let client = TestClient::new(app);
let res = client.get("/a/foo").send().await;
let res = client.get("/a/foo").await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "foo");
let res = client.get("/a/foo/").send().await;
let res = client.get("/a/foo/").await;
assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(res.headers()["location"], "/a/foo");
let res = client.get("/b/foo/").send().await;
let res = client.get("/b/foo/").await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "foo");
let res = client.get("/b/foo").send().await;
let res = client.get("/b/foo").await;
assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(res.headers()["location"], "/b/foo/");
}
@@ -404,11 +427,27 @@ mod tests {
let client = TestClient::new(app);
let res = client.get("/foo/?a=a").send().await;
let res = client.get("/foo/?a=a").await;
assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(res.headers()["location"], "/foo?a=a");
}
#[tokio::test]
async fn tsr_works_in_nested_router() {
let app = Router::new().nest(
"/neko",
Router::new().route_with_tsr("/nyan/", get(|| async {})),
);
let client = TestClient::new(app);
let res = client.get("/neko/nyan/").await;
assert_eq!(res.status(), StatusCode::OK);
let res = client.get("/neko/nyan").await;
assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(res.headers()["location"], "/neko/nyan/");
}
#[test]
#[should_panic = "Cannot add a trailing slash redirect route for `/`"]
fn tsr_at_root() {
+19 -11
View File
@@ -19,13 +19,13 @@ use axum::{
/// .create(|| async {})
/// // `GET /users/new`
/// .new(|| async {})
/// // `GET /users/:users_id`
/// // `GET /users/{users_id}`
/// .show(|Path(user_id): Path<u64>| async {})
/// // `GET /users/:users_id/edit`
/// // `GET /users/{users_id}/edit`
/// .edit(|Path(user_id): Path<u64>| async {})
/// // `PUT or PATCH /users/:users_id`
/// // `PUT or PATCH /users/{users_id}`
/// .update(|Path(user_id): Path<u64>| async {})
/// // `DELETE /users/:users_id`
/// // `DELETE /users/{users_id}`
/// .destroy(|Path(user_id): Path<u64>| async {});
///
/// let app = Router::new().merge(users);
@@ -82,7 +82,9 @@ where
self.route(&path, get(handler))
}
/// Add a handler at `GET /{resource_name}/:{resource_name}_id`.
/// Add a handler at `GET /<resource_name>/{<resource_name>_id}`.
///
/// For example when the resources are posts: `GET /post/{post_id}`.
pub fn show<H, T>(self, handler: H) -> Self
where
H: Handler<T, S>,
@@ -92,17 +94,21 @@ where
self.route(&path, get(handler))
}
/// Add a handler at `GET /{resource_name}/:{resource_name}_id/edit`.
/// Add a handler at `GET /<resource_name>/{<resource_name>_id}/edit`.
///
/// For example when the resources are posts: `GET /post/{post_id}/edit`.
pub fn edit<H, T>(self, handler: H) -> Self
where
H: Handler<T, S>,
T: 'static,
{
let path = format!("/{0}/:{0}_id/edit", self.name);
let path = format!("/{0}/{{{0}_id}}/edit", self.name);
self.route(&path, get(handler))
}
/// Add a handler at `PUT or PATCH /resource_name/:{resource_name}_id`.
/// Add a handler at `PUT or PATCH /<resource_name>/{<resource_name>_id}`.
///
/// For example when the resources are posts: `PUT /post/{post_id}`.
pub fn update<H, T>(self, handler: H) -> Self
where
H: Handler<T, S>,
@@ -115,7 +121,9 @@ where
)
}
/// Add a handler at `DELETE /{resource_name}/:{resource_name}_id`.
/// Add a handler at `DELETE /<resource_name>/{<resource_name>_id}`.
///
/// For example when the resources are posts: `DELETE /post/{post_id}`.
pub fn destroy<H, T>(self, handler: H) -> Self
where
H: Handler<T, S>,
@@ -130,7 +138,7 @@ where
}
fn show_update_destroy_path(&self) -> String {
format!("/{0}/:{0}_id", self.name)
format!("/{0}/{{{0}_id}}", self.name)
}
fn route(mut self, path: &str, method_router: MethodRouter<S>) -> Self {
@@ -149,7 +157,7 @@ impl<S> From<Resource<S>> for Router<S> {
mod tests {
#[allow(unused_imports)]
use super::*;
use axum::{body::Body, extract::Path, http::Method, Router};
use axum::{body::Body, extract::Path, http::Method};
use http::Request;
use http_body_util::BodyExt;
use tower::ServiceExt;
+47 -18
View File
@@ -19,15 +19,15 @@ use serde::Serialize;
/// RouterExt, // for `Router::typed_*`
/// };
///
/// // A type safe route with `/users/:id` as its associated path.
/// // A type safe route with `/users/{id}` as its associated path.
/// #[derive(TypedPath, Deserialize)]
/// #[typed_path("/users/:id")]
/// #[typed_path("/users/{id}")]
/// struct UsersMember {
/// id: u32,
/// }
///
/// // A regular handler function that takes `UsersMember` as the first argument
/// // and thus creates a typed connection between this handler and the `/users/:id` path.
/// // and thus creates a typed connection between this handler and the `/users/{id}` path.
/// //
/// // The `TypedPath` must be the first argument to the function.
/// async fn users_show(
@@ -39,7 +39,7 @@ use serde::Serialize;
/// let app = Router::new()
/// // Add our typed route to the router.
/// //
/// // The path will be inferred to `/users/:id` since `users_show`'s
/// // The path will be inferred to `/users/{id}` since `users_show`'s
/// // first argument is `UsersMember` which implements `TypedPath`
/// .typed_get(users_show)
/// .typed_post(users_create)
@@ -75,7 +75,7 @@ use serde::Serialize;
/// use axum_extra::routing::TypedPath;
///
/// #[derive(TypedPath, Deserialize)]
/// #[typed_path("/users/:id")]
/// #[typed_path("/users/{id}")]
/// struct UsersMember {
/// id: u32,
/// }
@@ -85,12 +85,12 @@ use serde::Serialize;
///
/// - A `TypedPath` implementation.
/// - A [`FromRequest`] implementation compatible with [`RouterExt::typed_get`],
/// [`RouterExt::typed_post`], etc. This implementation uses [`Path`] and thus your struct must
/// also implement [`serde::Deserialize`], unless it's a unit struct.
/// [`RouterExt::typed_post`], etc. This implementation uses [`Path`] and thus your struct must
/// also implement [`serde::Deserialize`], unless it's a unit struct.
/// - A [`Display`] implementation that interpolates the captures. This can be used to, among other
/// things, create links to known paths and have them verified statically. Note that the
/// [`Display`] implementation for each field must return something that's compatible with its
/// [`Deserialize`] implementation.
/// things, create links to known paths and have them verified statically. Note that the
/// [`Display`] implementation for each field must return something that's compatible with its
/// [`Deserialize`] implementation.
///
/// Additionally the macro will verify the captures in the path matches the fields of the struct.
/// For example this fails to compile since the struct doesn't have a `team_id` field:
@@ -100,7 +100,7 @@ use serde::Serialize;
/// use axum_extra::routing::TypedPath;
///
/// #[derive(TypedPath, Deserialize)]
/// #[typed_path("/users/:id/teams/:team_id")]
/// #[typed_path("/users/{id}/teams/{team_id}")]
/// struct UsersMember {
/// id: u32,
/// }
@@ -117,7 +117,7 @@ use serde::Serialize;
/// struct UsersCollection;
///
/// #[derive(TypedPath, Deserialize)]
/// #[typed_path("/users/:id")]
/// #[typed_path("/users/{id}")]
/// struct UsersMember(u32);
/// ```
///
@@ -130,7 +130,7 @@ use serde::Serialize;
/// use axum_extra::routing::TypedPath;
///
/// #[derive(TypedPath, Deserialize)]
/// #[typed_path("/users/:id")]
/// #[typed_path("/users/{id}")]
/// struct UsersMember {
/// id: String,
/// }
@@ -158,7 +158,7 @@ use serde::Serialize;
/// };
///
/// #[derive(TypedPath, Deserialize)]
/// #[typed_path("/users/:id", rejection(UsersMemberRejection))]
/// #[typed_path("/users/{id}", rejection(UsersMemberRejection))]
/// struct UsersMember {
/// id: String,
/// }
@@ -215,7 +215,7 @@ use serde::Serialize;
/// [`Deserialize`]: serde::Deserialize
/// [`PathRejection`]: axum::extract::rejection::PathRejection
pub trait TypedPath: std::fmt::Display {
/// The path with optional captures such as `/users/:id`.
/// The path with optional captures such as `/users/{id}`.
const PATH: &'static str;
/// Convert the path into a `Uri`.
@@ -321,7 +321,7 @@ where
/// Utility trait used with [`RouterExt`] to ensure the second element of a tuple type is a
/// given type.
///
/// If you see it in type errors its most likely because the second argument to your handler doesn't
/// If you see it in type errors it's most likely because the second argument to your handler doesn't
/// implement [`TypedPath`].
///
/// You normally shouldn't have to use this trait directly.
@@ -386,11 +386,19 @@ impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13,
#[cfg(test)]
mod tests {
use super::*;
use crate::routing::TypedPath;
use crate::{
extract::WithRejection,
routing::{RouterExt, TypedPath},
};
use axum::{
extract::rejection::PathRejection,
response::{IntoResponse, Response},
Router,
};
use serde::Deserialize;
#[derive(TypedPath, Deserialize)]
#[typed_path("/users/:id")]
#[typed_path("/users/{id}")]
struct UsersShow {
id: i32,
}
@@ -434,4 +442,25 @@ mod tests {
assert_eq!(uri, "/users/1?&foo=foo&bar=123&baz=true&qux=1337");
}
#[allow(dead_code)] // just needs to compile
fn supports_with_rejection() {
async fn handler(_: WithRejection<UsersShow, MyRejection>) {}
struct MyRejection {}
impl IntoResponse for MyRejection {
fn into_response(self) -> Response {
unimplemented!()
}
}
impl From<PathRejection> for MyRejection {
fn from(_: PathRejection) -> Self {
unimplemented!()
}
}
let _: Router = Router::new().typed_get(handler);
}
}
+27 -9
View File
@@ -1,12 +1,11 @@
//! Extractor and response for typed headers.
use axum::{
async_trait,
extract::FromRequestParts,
response::{IntoResponse, IntoResponseParts, Response, ResponseParts},
};
use headers::{Header, HeaderMapExt};
use http::request::Parts;
use http::{request::Parts, StatusCode};
use std::convert::Infallible;
/// Extractor and response that works with typed header values from [`headers`].
@@ -30,7 +29,7 @@ use std::convert::Infallible;
/// // ...
/// }
///
/// let app = Router::new().route("/users/:user_id/team/:team_id", get(users_teams_show));
/// let app = Router::new().route("/users/{user_id}/team/{team_id}", get(users_teams_show));
/// # let _: Router = app;
/// ```
///
@@ -55,7 +54,6 @@ use std::convert::Infallible;
#[must_use]
pub struct TypedHeader<T>(pub T);
#[async_trait]
impl<T, S> FromRequestParts<S> for TypedHeader<T>
where
T: Header,
@@ -123,6 +121,14 @@ impl TypedHeaderRejection {
pub fn reason(&self) -> &TypedHeaderRejectionReason {
&self.reason
}
/// Returns `true` if the typed header rejection reason is [`Missing`].
///
/// [`Missing`]: TypedHeaderRejectionReason::Missing
#[must_use]
pub fn is_missing(&self) -> bool {
self.reason.is_missing()
}
}
/// Additional information regarding a [`TypedHeaderRejection`]
@@ -136,9 +142,22 @@ pub enum TypedHeaderRejectionReason {
Error(headers::Error),
}
impl TypedHeaderRejectionReason {
/// Returns `true` if the typed header rejection reason is [`Missing`].
///
/// [`Missing`]: TypedHeaderRejectionReason::Missing
#[must_use]
pub fn is_missing(&self) -> bool {
matches!(self, Self::Missing)
}
}
impl IntoResponse for TypedHeaderRejection {
fn into_response(self) -> Response {
(http::StatusCode::BAD_REQUEST, self.to_string()).into_response()
let status = StatusCode::BAD_REQUEST;
let body = self.to_string();
axum_core::__log_rejection!(rejection_type = Self, body_text = body, status = status,);
(status, body).into_response()
}
}
@@ -168,7 +187,7 @@ impl std::error::Error for TypedHeaderRejection {
mod tests {
use super::*;
use crate::test_helpers::*;
use axum::{response::IntoResponse, routing::get, Router};
use axum::{routing::get, Router};
#[tokio::test]
async fn typed_header() {
@@ -190,7 +209,6 @@ mod tests {
.header("user-agent", "foobar")
.header("cookie", "a=1; b=2")
.header("cookie", "c=3")
.send()
.await;
let body = res.text().await;
assert_eq!(
@@ -198,11 +216,11 @@ mod tests {
r#"User-Agent="foobar", Cookie=[("a", "1"), ("b", "2"), ("c", "3")]"#
);
let res = client.get("/").header("user-agent", "foobar").send().await;
let res = client.get("/").header("user-agent", "foobar").await;
let body = res.text().await;
assert_eq!(body, r#"User-Agent="foobar", Cookie=[]"#);
let res = client.get("/").header("cookie", "a=1").send().await;
let res = client.get("/").header("cookie", "a=1").await;
let body = res.text().await;
assert_eq!(body, "Header of type `user-agent` was missing");
}