diff --git a/axum-core/CHANGELOG.md b/axum-core/CHANGELOG.md index 9d8e745644..b13680812a 100644 --- a/axum-core/CHANGELOG.md +++ b/axum-core/CHANGELOG.md @@ -7,7 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased -- None. +- **breaking:** Using `HeaderMap` as an extractor will no longer remove the headers and thus + they'll still be accessible to other extractors, such as `axum::extract::Json`. Instead + `HeaderMap` will clone the headers. You should prefer to use `TypedHeader` to extract only the + headers you need ([#698]) + + This includes these breaking changes: + - `RequestParts::take_headers` has been removed. + - `RequestParts::headers` returns `&HeaderMap`. + - `RequestParts::headers_mut` returns `&mut HeaderMap`. + - `HeadersAlreadyExtracted` has been removed. + - The `HeadersAlreadyExtracted` variant has been removed from these rejections: + - `RequestAlreadyExtracted` + - `RequestPartsAlreadyExtracted` + - `>::Error` has been changed to `std::convert::Infallible`. +- **breaking:** `axum::http::Extensions` is no longer an extractor (ie it + doesn't implement `FromRequest`). The `axum::extract::Extension` extractor is + _not_ impacted by this and works the same. This change makes it harder to + accidentally remove all extensions which would result in confusing errors + elsewhere ([#699]) + This includes these breaking changes: + - `RequestParts::take_extensions` has been removed. + - `RequestParts::extensions` returns `&Extensions`. + - `RequestParts::extensions_mut` returns `&mut Extensions`. + - `RequestAlreadyExtracted` has been removed. + - `::Error` is now `BodyAlreadyExtracted`. + - `::Error` is now `Infallible`. + - `ExtensionsAlreadyExtracted` has been removed. + +[#698]: https://github.com/tokio-rs/axum/pull/698 +[#699]: https://github.com/tokio-rs/axum/pull/699 # 0.1.1 (06. December, 2021) diff --git a/axum-core/src/error.rs b/axum-core/src/error.rs index 93e5295f6a..f578803479 100644 --- a/axum-core/src/error.rs +++ b/axum-core/src/error.rs @@ -14,16 +14,6 @@ impl Error { inner: error.into(), } } - - pub(crate) fn downcast(self) -> Result - where - T: StdError + 'static, - { - match self.inner.downcast::() { - Ok(t) => Ok(*t), - Err(err) => Err(*err.downcast().unwrap()), - } - } } impl fmt::Display for Error { diff --git a/axum-core/src/extract/mod.rs b/axum-core/src/extract/mod.rs index c3951a67ee..1a85da81c9 100644 --- a/axum-core/src/extract/mod.rs +++ b/axum-core/src/extract/mod.rs @@ -6,7 +6,6 @@ use self::rejection::*; use crate::response::IntoResponse; -use crate::Error; use async_trait::async_trait; use http::{Extensions, HeaderMap, Method, Request, Uri, Version}; use std::convert::Infallible; @@ -78,8 +77,8 @@ pub struct RequestParts { method: Method, uri: Uri, version: Version, - headers: Option, - extensions: Option, + headers: HeaderMap, + extensions: Extensions, body: Option, } @@ -108,63 +107,39 @@ impl RequestParts { method, uri, version, - headers: Some(headers), - extensions: Some(extensions), + headers, + extensions, body: Some(body), } } /// Convert this `RequestParts` back into a [`Request`]. /// - /// Fails if + /// Fails if The request body has been extracted, that is [`take_body`] has + /// been called. /// - /// - The full [`HeaderMap`] has been extracted, that is [`take_headers`] - /// have been called. - /// - The full [`Extensions`] has been extracted, that is - /// [`take_extensions`] have been called. - /// - The request body has been extracted, that is [`take_body`] have been - /// called. - /// - /// [`take_headers`]: RequestParts::take_headers - /// [`take_extensions`]: RequestParts::take_extensions /// [`take_body`]: RequestParts::take_body - pub fn try_into_request(self) -> Result, Error> { + pub fn try_into_request(self) -> Result, BodyAlreadyExtracted> { let Self { method, uri, version, - mut headers, - mut extensions, + headers, + extensions, mut body, } = self; let mut req = if let Some(body) = body.take() { Request::new(body) } else { - return Err(Error::new(RequestAlreadyExtracted::BodyAlreadyExtracted( - BodyAlreadyExtracted, - ))); + return Err(BodyAlreadyExtracted); }; *req.method_mut() = method; *req.uri_mut() = uri; *req.version_mut() = version; - - if let Some(headers) = headers.take() { - *req.headers_mut() = headers; - } else { - return Err(Error::new( - RequestAlreadyExtracted::HeadersAlreadyExtracted(HeadersAlreadyExtracted), - )); - } - - if let Some(extensions) = extensions.take() { - *req.extensions_mut() = extensions; - } else { - return Err(Error::new( - RequestAlreadyExtracted::ExtensionsAlreadyExtracted(ExtensionsAlreadyExtracted), - )); - } + *req.headers_mut() = headers; + *req.extensions_mut() = extensions; Ok(req) } @@ -200,41 +175,23 @@ impl RequestParts { } /// Gets a reference to the request headers. - /// - /// Returns `None` if the headers has been taken by another extractor. - pub fn headers(&self) -> Option<&HeaderMap> { - self.headers.as_ref() + pub fn headers(&self) -> &HeaderMap { + &self.headers } /// Gets a mutable reference to the request headers. - /// - /// Returns `None` if the headers has been taken by another extractor. - pub fn headers_mut(&mut self) -> Option<&mut HeaderMap> { - self.headers.as_mut() - } - - /// Takes the headers out of the request, leaving a `None` in its place. - pub fn take_headers(&mut self) -> Option { - self.headers.take() + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers } /// Gets a reference to the request extensions. - /// - /// Returns `None` if the extensions has been taken by another extractor. - pub fn extensions(&self) -> Option<&Extensions> { - self.extensions.as_ref() + pub fn extensions(&self) -> &Extensions { + &self.extensions } /// Gets a mutable reference to the request extensions. - /// - /// Returns `None` if the extensions has been taken by another extractor. - pub fn extensions_mut(&mut self) -> Option<&mut Extensions> { - self.extensions.as_mut() - } - - /// Takes the extensions out of the request, leaving a `None` in its place. - pub fn take_extensions(&mut self) -> Option { - self.extensions.take() + pub fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.extensions } /// Gets a reference to the request body. diff --git a/axum-core/src/extract/rejection.rs b/axum-core/src/extract/rejection.rs index ad4f0eebed..10440e49b4 100644 --- a/axum-core/src/extract/rejection.rs +++ b/axum-core/src/extract/rejection.rs @@ -1,28 +1,36 @@ //! Rejection response types. -define_rejection! { - #[status = INTERNAL_SERVER_ERROR] - #[body = "Cannot have two request body extractors for a single handler"] - /// Rejection type used if you try and extract the request body more than - /// once. - pub struct BodyAlreadyExtracted; +use crate::body; +use http::{Response, StatusCode}; +use http_body::Full; +use std::fmt; + +/// Rejection type used if you try and extract the request body more than +/// once. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct BodyAlreadyExtracted; + +impl BodyAlreadyExtracted { + const BODY: &'static str = "Cannot have two request body extractors for a single handler"; } -define_rejection! { - #[status = INTERNAL_SERVER_ERROR] - #[body = "Headers taken by other extractor"] - /// Rejection used if the headers has been taken by another extractor. - pub struct HeadersAlreadyExtracted; +impl crate::response::IntoResponse for BodyAlreadyExtracted { + fn into_response(self) -> crate::response::Response { + let mut res = Response::new(body::boxed(Full::from(Self::BODY))); + *res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + res + } } -define_rejection! { - #[status = INTERNAL_SERVER_ERROR] - #[body = "Extensions taken by other extractor"] - /// Rejection used if the request extension has been taken by another - /// extractor. - pub struct ExtensionsAlreadyExtracted; +impl fmt::Display for BodyAlreadyExtracted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", Self::BODY) + } } +impl std::error::Error for BodyAlreadyExtracted {} + define_rejection! { #[status = BAD_REQUEST] #[body = "Failed to buffer the request body"] @@ -39,19 +47,6 @@ define_rejection! { pub struct InvalidUtf8(Error); } -composite_rejection! { - /// Rejection used for [`Request<_>`]. - /// - /// Contains one variant for each way the [`Request<_>`] extractor can fail. - /// - /// [`Request<_>`]: http::Request - pub enum RequestAlreadyExtracted { - BodyAlreadyExtracted, - HeadersAlreadyExtracted, - ExtensionsAlreadyExtracted, - } -} - composite_rejection! { /// Rejection used for [`Bytes`](bytes::Bytes). /// @@ -73,13 +68,3 @@ composite_rejection! { InvalidUtf8, } } - -composite_rejection! { - /// Rejection used for [`http::request::Parts`]. - /// - /// Contains one variant for each way the [`http::request::Parts`] extractor can fail. - pub enum RequestPartsAlreadyExtracted { - HeadersAlreadyExtracted, - ExtensionsAlreadyExtracted, - } -} diff --git a/axum-core/src/extract/request_parts.rs b/axum-core/src/extract/request_parts.rs index 4752a8d72a..33383a7d8d 100644 --- a/axum-core/src/extract/request_parts.rs +++ b/axum-core/src/extract/request_parts.rs @@ -10,7 +10,7 @@ impl FromRequest for Request where B: Send, { - type Rejection = RequestAlreadyExtracted; + type Rejection = BodyAlreadyExtracted; async fn from_request(req: &mut RequestParts) -> Result { let req = std::mem::replace( @@ -19,24 +19,13 @@ where method: req.method.clone(), version: req.version, uri: req.uri.clone(), - headers: None, - extensions: None, + headers: HeaderMap::new(), + extensions: Extensions::default(), body: None, }, ); - let err = match req.try_into_request() { - Ok(req) => return Ok(req), - Err(err) => err, - }; - - match err.downcast::() { - Ok(err) => return Err(err), - Err(err) => unreachable!( - "Unexpected error type from `try_into_request`: `{:?}`. This is a bug in axum, please file an issue", - err, - ), - } + req.try_into_request() } } @@ -76,27 +65,20 @@ where } } +/// Clone the headers from the request. +/// +/// Prefer using [`TypedHeader`] to extract only the headers you need. +/// +/// [`TypedHeader`]: https://docs.rs/axum/latest/axum/extract/struct.TypedHeader.html #[async_trait] impl FromRequest for HeaderMap where B: Send, { - type Rejection = HeadersAlreadyExtracted; - - async fn from_request(req: &mut RequestParts) -> Result { - req.take_headers().ok_or(HeadersAlreadyExtracted) - } -} - -#[async_trait] -impl FromRequest for Extensions -where - B: Send, -{ - type Rejection = ExtensionsAlreadyExtracted; + type Rejection = Infallible; async fn from_request(req: &mut RequestParts) -> Result { - req.take_extensions().ok_or(ExtensionsAlreadyExtracted) + Ok(req.headers().clone()) } } @@ -148,14 +130,14 @@ impl FromRequest for http::request::Parts where B: Send, { - type Rejection = RequestPartsAlreadyExtracted; + type Rejection = Infallible; async fn from_request(req: &mut RequestParts) -> Result { let method = unwrap_infallible(Method::from_request(req).await); let uri = unwrap_infallible(Uri::from_request(req).await); let version = unwrap_infallible(Version::from_request(req).await); - let headers = HeaderMap::from_request(req).await?; - let extensions = Extensions::from_request(req).await?; + let headers = unwrap_infallible(HeaderMap::from_request(req).await); + let extensions = std::mem::take(req.extensions_mut()); let mut temp_request = Request::new(()); *temp_request.method_mut() = method; diff --git a/axum-core/src/macros.rs b/axum-core/src/macros.rs index 77c428882a..723e319f58 100644 --- a/axum-core/src/macros.rs +++ b/axum-core/src/macros.rs @@ -1,39 +1,4 @@ macro_rules! define_rejection { - ( - #[status = $status:ident] - #[body = $body:expr] - $(#[$m:meta])* - pub struct $name:ident; - ) => { - $(#[$m])* - #[derive(Debug)] - #[non_exhaustive] - pub struct $name; - - #[allow(deprecated)] - impl $crate::response::IntoResponse for $name { - fn into_response(self) -> $crate::response::Response { - let mut res = http::Response::new($crate::body::boxed(http_body::Full::from($body))); - *res.status_mut() = http::StatusCode::$status; - res - } - } - - impl std::fmt::Display for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", $body) - } - } - - impl std::error::Error for $name {} - - impl Default for $name { - fn default() -> Self { - Self - } - } - }; - ( #[status = $status:ident] #[body = $body:expr] diff --git a/axum-core/src/response/headers.rs b/axum-core/src/response/headers.rs index 56455481e9..7176ff9818 100644 --- a/axum-core/src/response/headers.rs +++ b/axum-core/src/response/headers.rs @@ -1,11 +1,8 @@ -use super::{IntoResponse, Response}; -use crate::body::boxed; -use bytes::Bytes; +use super::{IntoResponse, IntoResponseHeaders, Response}; use http::{ - header::{HeaderMap, HeaderName, HeaderValue}, + header::{HeaderName, HeaderValue}, StatusCode, }; -use http_body::{Empty, Full}; use std::{convert::TryInto, fmt}; /// A response with headers. @@ -54,38 +51,7 @@ use std::{convert::TryInto, fmt}; #[derive(Clone, Copy, Debug)] pub struct Headers(pub H); -impl Headers { - fn try_into_header_map(self) -> Result - where - H: IntoIterator, - K: TryInto, - K::Error: fmt::Display, - V: TryInto, - V::Error: fmt::Display, - { - self.0 - .into_iter() - .map(|(key, value)| { - let key = key.try_into().map_err(Either::A)?; - let value = value.try_into().map_err(Either::B)?; - Ok((key, value)) - }) - .collect::>() - .map_err(|err| { - let err = match err { - Either::A(err) => err.to_string(), - Either::B(err) => err.to_string(), - }; - - let body = boxed(Full::new(Bytes::copy_from_slice(err.as_bytes()))); - let mut res = Response::new(body); - *res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - res - }) - } -} - -impl IntoResponse for Headers +impl IntoResponseHeaders for Headers where H: IntoIterator, K: TryInto, @@ -93,63 +59,45 @@ where V: TryInto, V::Error: fmt::Display, { - fn into_response(self) -> Response { - let headers = self.try_into_header_map(); - - match headers { - Ok(headers) => { - let mut res = Response::new(boxed(Empty::new())); - *res.headers_mut() = headers; - res - } - Err(err) => err, + type IntoIter = IntoIter; + + fn into_headers(self) -> Self::IntoIter { + IntoIter { + inner: self.0.into_iter(), } } } -impl IntoResponse for (Headers, T) -where - T: IntoResponse, - H: IntoIterator, - K: TryInto, - K::Error: fmt::Display, - V: TryInto, - V::Error: fmt::Display, -{ - fn into_response(self) -> Response { - let headers = match self.0.try_into_header_map() { - Ok(headers) => headers, - Err(res) => return res, - }; - - (headers, self.1).into_response() - } +#[doc(hidden)] +#[derive(Debug)] +pub struct IntoIter { + inner: H, } -impl IntoResponse for (StatusCode, Headers, T) +impl Iterator for IntoIter where - T: IntoResponse, - H: IntoIterator, + H: Iterator, K: TryInto, K::Error: fmt::Display, V: TryInto, V::Error: fmt::Display, { - fn into_response(self) -> Response { - let headers = match self.1.try_into_header_map() { - Ok(headers) => headers, - Err(res) => return res, - }; - - (self.0, headers, self.2).into_response() + type Item = Result<(Option, HeaderValue), Response>; + + fn next(&mut self) -> Option { + self.inner.next().map(|(key, value)| { + let key = key + .try_into() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response())?; + let value = value + .try_into() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response())?; + + Ok((Some(key), value)) + }) } } -enum Either { - A(A), - B(B), -} - #[cfg(test)] mod tests { use super::*; diff --git a/axum-core/src/response/mod.rs b/axum-core/src/response/mod.rs index 69c14870db..534351c22e 100644 --- a/axum-core/src/response/mod.rs +++ b/axum-core/src/response/mod.rs @@ -10,14 +10,14 @@ use crate::{ }; use bytes::Bytes; use http::{ - header::{self, HeaderMap, HeaderValue}, + header::{self, HeaderMap, HeaderName, HeaderValue}, StatusCode, }; use http_body::{ combinators::{MapData, MapErr}, Empty, Full, }; -use std::{borrow::Cow, convert::Infallible}; +use std::{borrow::Cow, convert::Infallible, iter}; mod headers; @@ -148,6 +148,42 @@ pub trait IntoResponse { fn into_response(self) -> Response; } +/// Trait for generating response headers. +/// + +/// **Note: If you see this trait not being implemented in an error message, you are almost +/// certainly being mislead by the compiler¹. Look for the following snippet in the output and +/// check [`IntoResponse`]'s documentation if you find it:** +/// +/// ```text +/// note: required because of the requirements on the impl of `IntoResponse` for `` +/// ``` +/// +/// Any type that implements this trait automatically implements `IntoResponse` as well, but can +/// also be used in a tuple like `(StatusCode, Self)`, `(Self, impl IntoResponseHeaders)`, +/// `(StatusCode, Self, impl IntoResponseHeaders, impl IntoResponse)` and so on. +/// +/// This trait can't currently be implemented outside of axum. +/// +/// ¹ See also [this rustc issue](https://github.com/rust-lang/rust/issues/22590) +pub trait IntoResponseHeaders { + /// The return type of [`.into_headers()`]. + /// + /// The iterator item is a `Result` to allow the implementation to return a server error + /// instead. + /// + /// The header name is optional because `HeaderMap`s iterator doesn't yield it multiple times + /// for headers that have multiple values, to avoid unnecessary copies. + #[doc(hidden)] + type IntoIter: IntoIterator, HeaderValue), Response>>; + + /// Attempt to turn `self` into a list of headers. + /// + /// In practice, only the implementation for `axum::response::Headers` ever returns `Err(_)`. + #[doc(hidden)] + fn into_headers(self) -> Self::IntoIter; +} + impl IntoResponse for () { fn into_response(self) -> Response { Response::new(boxed(Empty::new())) @@ -320,6 +356,21 @@ impl IntoResponse for StatusCode { } } +impl IntoResponse for H +where + H: IntoResponseHeaders, +{ + fn into_response(self) -> Response { + let mut res = Response::new(boxed(Empty::new())); + + if let Err(e) = try_extend_headers(res.headers_mut(), self.into_headers()) { + return e; + } + + res + } +} + impl IntoResponse for (StatusCode, T) where T: IntoResponse, @@ -331,33 +382,98 @@ where } } -impl IntoResponse for (HeaderMap, T) +impl IntoResponse for (H, T) where + H: IntoResponseHeaders, T: IntoResponse, { fn into_response(self) -> Response { let mut res = self.1.into_response(); - res.headers_mut().extend(self.0); + + if let Err(e) = try_extend_headers(res.headers_mut(), self.0.into_headers()) { + return e; + } + res } } -impl IntoResponse for (StatusCode, HeaderMap, T) +impl IntoResponse for (StatusCode, H, T) where + H: IntoResponseHeaders, T: IntoResponse, { fn into_response(self) -> Response { let mut res = self.2.into_response(); *res.status_mut() = self.0; - res.headers_mut().extend(self.1); + + if let Err(e) = try_extend_headers(res.headers_mut(), self.1.into_headers()) { + return e; + } + res } } -impl IntoResponse for HeaderMap { - fn into_response(self) -> Response { - let mut res = Response::new(boxed(Empty::new())); - *res.headers_mut() = self; - res +impl IntoResponseHeaders for HeaderMap { + // FIXME: Use type_alias_impl_trait when available + type IntoIter = iter::Map< + http::header::IntoIter, + fn( + (Option, HeaderValue), + ) -> Result<(Option, HeaderValue), Response>, + >; + + fn into_headers(self) -> Self::IntoIter { + self.into_iter().map(Ok) + } +} + +// Slightly adjusted version of `impl Extend<(Option, T)> for HeaderMap`. +// Accepts an iterator that returns Results and short-circuits on an `Err`. +fn try_extend_headers( + headers: &mut HeaderMap, + iter: impl IntoIterator, HeaderValue), Response>>, +) -> Result<(), Response> { + use http::header::Entry; + + let mut iter = iter.into_iter(); + + // The structure of this is a bit weird, but it is mostly to make the + // borrow checker happy. + let (mut key, mut val) = match iter.next().transpose()? { + Some((Some(key), val)) => (key, val), + Some((None, _)) => panic!("expected a header name, but got None"), + None => return Ok(()), + }; + + 'outer: loop { + let mut entry = match headers.entry(key) { + Entry::Occupied(mut e) => { + // Replace all previous values while maintaining a handle to + // the entry. + e.insert(val); + e + } + Entry::Vacant(e) => e.insert_entry(val), + }; + + // As long as `HeaderName` is none, keep inserting the value into + // the current entry + loop { + match iter.next().transpose()? { + Some((Some(k), v)) => { + key = k; + val = v; + continue 'outer; + } + Some((None, v)) => { + entry.append(v); + } + None => { + return Ok(()); + } + } + } } } diff --git a/axum-debug/src/lib.rs b/axum-debug/src/lib.rs index 2c58365ddd..e25919fd80 100644 --- a/axum-debug/src/lib.rs +++ b/axum-debug/src/lib.rs @@ -21,7 +21,7 @@ //! ``` //! //! You will get a long error message about function not implementing [`Handler`] trait. But why -//! this function does not implement it? To figure it out [`debug_handler`] macro can be used. +//! does this function not implement it? To figure it out, the [`debug_handler`] macro can be used. //! //! ```rust,compile_fail //! # use axum::{routing::get, Router}; @@ -89,6 +89,34 @@ //! async fn handler(request: Request) {} //! ``` //! +//! # Known limitations +//! +//! If your response type doesn't implement `IntoResponse`, you will get a slightly confusing error +//! message: +//! +//! ```text +//! error[E0277]: the trait bound `bool: axum_core::response::IntoResponseHeaders` is not satisfied +//! --> tests/fail/wrong_return_type.rs:4:23 +//! | +//! 4 | async fn handler() -> bool { +//! | ^^^^ the trait `axum_core::response::IntoResponseHeaders` is not implemented for `bool` +//! | +//! = note: required because of the requirements on the impl of `IntoResponse` for `bool` +//! note: required by a bound in `__axum_debug_check_handler_into_response::{closure#0}::check` +//! --> tests/fail/wrong_return_type.rs:4:23 +//! | +//! 4 | async fn handler() -> bool { +//! | ^^^^ required by this bound in `__axum_debug_check_handler_into_response::{closure#0}::check` +//! ``` +//! +//! The main error message when `IntoResponse` isn't implemented will also ways be for a different +//! trait, `IntoResponseHeaders`, not being implemented. This trait is not meant to be implemented +//! for types outside of axum and what you really need to do is change your return type or implement +//! `IntoResponse` for it (if it is your own type that you want to return directly from handlers). +//! +//! This issue is not specific to axum and cannot be fixed by us. For more details, see the +//! [rustc issue about it](https://github.com/rust-lang/rust/issues/22590). +//! //! # Performance //! //! Macros in this crate have no effect when using release profile. (eg. `cargo build --release`) diff --git a/axum-debug/tests/fail/wrong_return_type.stderr b/axum-debug/tests/fail/wrong_return_type.stderr index 596ba1d746..55e5c2698f 100644 --- a/axum-debug/tests/fail/wrong_return_type.stderr +++ b/axum-debug/tests/fail/wrong_return_type.stderr @@ -1,9 +1,10 @@ -error[E0277]: the trait bound `bool: IntoResponse` is not satisfied +error[E0277]: the trait bound `bool: axum_core::response::IntoResponseHeaders` is not satisfied --> tests/fail/wrong_return_type.rs:4:23 | 4 | async fn handler() -> bool { - | ^^^^ the trait `IntoResponse` is not implemented for `bool` + | ^^^^ the trait `axum_core::response::IntoResponseHeaders` is not implemented for `bool` | + = note: required because of the requirements on the impl of `IntoResponse` for `bool` note: required by a bound in `__axum_debug_check_handler_into_response::{closure#0}::check` --> tests/fail/wrong_return_type.rs:4:23 | diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index 491a963e42..4ff725aa33 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -12,8 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # 0.1.2 (13. January, 2021) - **fix:** Depend on tower with `default_features = false` ([#666]) +- **breaking:** `CachedRejection` has been removed ([#699]) +- **breaking:** ` as FromRequest>::Error` is now `T::Rejection`. ([#699]) [#666]: https://github.com/tokio-rs/axum/pull/666 +[#699]: https://github.com/tokio-rs/axum/pull/699 # 0.1.1 (27. December, 2021) diff --git a/axum-extra/src/extract/cached.rs b/axum-extra/src/extract/cached.rs index 333b64ffce..0ada78a888 100644 --- a/axum-extra/src/extract/cached.rs +++ b/axum-extra/src/extract/cached.rs @@ -1,15 +1,8 @@ use axum::{ async_trait, - extract::{ - rejection::{ExtensionRejection, ExtensionsAlreadyExtracted}, - Extension, FromRequest, RequestParts, - }, - response::{IntoResponse, Response}, -}; -use std::{ - fmt, - ops::{Deref, DerefMut}, + extract::{Extension, FromRequest, RequestParts}, }; +use std::ops::{Deref, DerefMut}; /// Cache results of other extractors. /// @@ -100,25 +93,14 @@ where B: Send, T: FromRequest + Clone + Send + Sync + 'static, { - type Rejection = CachedRejection; + type Rejection = T::Rejection; async fn from_request(req: &mut RequestParts) -> Result { match Extension::>::from_request(req).await { Ok(Extension(CachedEntry(value))) => Ok(Self(value)), - Err(ExtensionRejection::ExtensionsAlreadyExtracted(err)) => { - Err(CachedRejection::ExtensionsAlreadyExtracted(err)) - } Err(_) => { - let value = T::from_request(req).await.map_err(CachedRejection::Inner)?; - - req.extensions_mut() - .ok_or_else(|| { - CachedRejection::ExtensionsAlreadyExtracted( - ExtensionsAlreadyExtracted::default(), - ) - })? - .insert(CachedEntry(value.clone())); - + let value = T::from_request(req).await?; + req.extensions_mut().insert(CachedEntry(value.clone())); Ok(Self(value)) } } @@ -139,54 +121,6 @@ impl DerefMut for Cached { } } -/// Rejection used for [`Cached`]. -/// -/// Contains one variant for each way the [`Cached`] extractor can fail. -#[derive(Debug)] -#[non_exhaustive] -pub enum CachedRejection { - #[allow(missing_docs)] - ExtensionsAlreadyExtracted(ExtensionsAlreadyExtracted), - #[allow(missing_docs)] - Inner(R), -} - -impl IntoResponse for CachedRejection -where - R: IntoResponse, -{ - fn into_response(self) -> Response { - match self { - Self::ExtensionsAlreadyExtracted(inner) => inner.into_response(), - Self::Inner(inner) => inner.into_response(), - } - } -} - -impl fmt::Display for CachedRejection -where - R: fmt::Display, -{ - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ExtensionsAlreadyExtracted(inner) => write!(f, "{}", inner), - Self::Inner(inner) => write!(f, "{}", inner), - } - } -} - -impl std::error::Error for CachedRejection -where - R: std::error::Error + 'static, -{ - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::ExtensionsAlreadyExtracted(inner) => Some(inner), - Self::Inner(inner) => Some(inner), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index 434fafa543..35c92b3ef1 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -3,9 +3,3 @@ mod cached; pub use self::cached::Cached; - -pub mod rejection { - //! Rejection response types. - - pub use super::cached::CachedRejection; -} diff --git a/axum/CHANGELOG.md b/axum/CHANGELOG.md index 592e319d11..69912f3e72 100644 --- a/axum/CHANGELOG.md +++ b/axum/CHANGELOG.md @@ -7,7 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased -- None. +- **breaking:** `sse::Event` now accepts types implementing `AsRef` instead of `Into` + as field values. +- **breaking:** `sse::Event` now panics if a setter method is called twice instead of silently + overwriting old values. +- **breaking:** Require `Output = ()` on `WebSocketStream::on_upgrade` ([#644]) +- **breaking:** Make `TypedHeaderRejectionReason` `#[non_exhaustive]` ([#665]) +- **breaking:** Using `HeaderMap` as an extractor will no longer remove the headers and thus + they'll still be accessible to other extractors, such as `axum::extract::Json`. Instead + `HeaderMap` will clone the headers. You should prefer to use `TypedHeader` to extract only the + headers you need ([#698]) + + This includes these breaking changes: + - `RequestParts::take_headers` has been removed. + - `RequestParts::headers` returns `&HeaderMap`. + - `RequestParts::headers_mut` returns `&mut HeaderMap`. + - `HeadersAlreadyExtracted` has been removed. + - The `HeadersAlreadyExtracted` removed variant has been removed from these rejections: + - `RequestAlreadyExtracted` + - `RequestPartsAlreadyExtracted` + - `JsonRejection` + - `FormRejection` + - `ContentLengthLimitRejection` + - `WebSocketUpgradeRejection` + - `>::Error` has been changed to `std::convert::Infallible`. +- **breaking:** `axum::http::Extensions` is no longer an extractor (ie it + doesn't implement `FromRequest`). The `axum::extract::Extension` extractor is + _not_ impacted by this and works the same. This change makes it harder to + accidentally remove all extensions which would result in confusing errors + elsewhere ([#699]) + This includes these breaking changes: + - `RequestParts::take_extensions` has been removed. + - `RequestParts::extensions` returns `&Extensions`. + - `RequestParts::extensions_mut` returns `&mut Extensions`. + - `RequestAlreadyExtracted` has been removed. + - `::Error` is now `BodyAlreadyExtracted`. + - `::Error` is now `Infallible`. + - `ExtensionsAlreadyExtracted` has been removed. + - The `ExtensionsAlreadyExtracted` removed variant has been removed from these rejections: + - `ExtensionRejection` + - `PathRejection` + - `MatchedPathRejection` + - `WebSocketUpgradeRejection` + +TODO(david): make sure everything from https://github.com/tokio-rs/axum/pull/644 +is mentioned here. + +[#644]: https://github.com/tokio-rs/axum/pull/644 +[#665]: https://github.com/tokio-rs/axum/pull/665 +[#698]: https://github.com/tokio-rs/axum/pull/698 +[#699]: https://github.com/tokio-rs/axum/pull/699 # 0.4.4 (13. January, 2021) diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 75c09ad59c..92cbbf0e57 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -11,11 +11,13 @@ readme = "README.md" repository = "https://github.com/tokio-rs/axum" [features] -default = ["http1", "json", "tower-log"] +default = ["http1", "json", "matched-path", "original-uri", "tower-log"] http1 = ["hyper/http1"] http2 = ["hyper/http2"] json = ["serde_json"] +matched-path = [] multipart = ["multer"] +original-uri = [] tower-log = ["tower/log"] ws = ["tokio-tungstenite", "sha-1", "base64"] @@ -27,10 +29,11 @@ bytes = "1.0" futures-util = { version = "0.3", default-features = false, features = ["alloc"] } http = "0.2.5" http-body = "0.4.4" -mime = "0.3.16" hyper = { version = "0.14.14", features = ["server", "tcp", "stream"] } +itoa = "1.0.1" matchit = "0.4.4" memchr = "2.4.1" +mime = "0.3.16" percent-encoding = "2.1" pin-project-lite = "0.2.7" serde = "1.0" @@ -53,7 +56,7 @@ tokio-tungstenite = { optional = true, version = "0.16" } [dev-dependencies] futures = "0.3" -reqwest = { version = "0.11", default-features = false, features = ["json", "stream"] } +reqwest = { version = "0.11", default-features = false, features = ["json", "stream", "multipart"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.6.1", features = ["macros", "rt", "rt-multi-thread", "net", "test-util"] } @@ -84,10 +87,10 @@ rustdoc-args = ["--cfg", "docsrs"] [package.metadata.playground] features = [ - "http1", - "http2", - "json", - "multipart", - "tower", - "ws", + "http1", + "http2", + "json", + "multipart", + "tower", + "ws", ] diff --git a/axum/src/docs/extract.md b/axum/src/docs/extract.md index 6f0f6853bb..5f4cec68e3 100644 --- a/axum/src/docs/extract.md +++ b/axum/src/docs/extract.md @@ -320,10 +320,6 @@ async fn handler(result: Result, JsonRejection>) -> impl IntoRespons StatusCode::INTERNAL_SERVER_ERROR, "Failed to buffer request body".to_string(), )), - JsonRejection::HeadersAlreadyExtracted(_) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "Headers already extracted".to_string(), - )), // we must provide a catch-all case since `JsonRejection` is marked // `#[non_exhaustive]` _ => Err(( @@ -377,9 +373,7 @@ where type Rejection = (StatusCode, &'static str); async fn from_request(req: &mut RequestParts) -> Result { - let user_agent = req.headers().and_then(|headers| headers.get(USER_AGENT)); - - if let Some(user_agent) = user_agent { + if let Some(user_agent) = req.headers().get(USER_AGENT) { Ok(ExtractUserAgent(user_agent.clone())) } else { Err((StatusCode::BAD_REQUEST, "`User-Agent` header is missing")) diff --git a/axum/src/extract/content_length_limit.rs b/axum/src/extract/content_length_limit.rs index c1f2143c94..7a41d2543f 100644 --- a/axum/src/extract/content_length_limit.rs +++ b/axum/src/extract/content_length_limit.rs @@ -39,14 +39,7 @@ where type Rejection = ContentLengthLimitRejection; async fn from_request(req: &mut RequestParts) -> Result { - let content_length = req - .headers() - .ok_or_else(|| { - ContentLengthLimitRejection::HeadersAlreadyExtracted( - HeadersAlreadyExtracted::default(), - ) - })? - .get(http::header::CONTENT_LENGTH); + let content_length = req.headers().get(http::header::CONTENT_LENGTH); let content_length = content_length.and_then(|value| value.to_str().ok()?.parse::().ok()); diff --git a/axum/src/extract/extension.rs b/axum/src/extract/extension.rs index d65fb3a3c4..110a23c54d 100644 --- a/axum/src/extract/extension.rs +++ b/axum/src/extract/extension.rs @@ -53,7 +53,6 @@ where async fn from_request(req: &mut RequestParts) -> Result { let value = req .extensions() - .ok_or_else(ExtensionsAlreadyExtracted::default)? .get::() .ok_or_else(|| { MissingExtension::from_err(format!( diff --git a/axum/src/extract/extractor_middleware.rs b/axum/src/extract/extractor_middleware.rs index bede119761..aca7b6f397 100644 --- a/axum/src/extract/extractor_middleware.rs +++ b/axum/src/extract/extractor_middleware.rs @@ -59,7 +59,7 @@ use tower_service::Service; /// async fn from_request(req: &mut RequestParts) -> Result { /// let auth_header = req /// .headers() -/// .and_then(|headers| headers.get(http::header::AUTHORIZATION)) +/// .get(http::header::AUTHORIZATION) /// .and_then(|value| value.to_str().ok()); /// /// match auth_header { @@ -291,7 +291,6 @@ mod tests { async fn from_request(req: &mut RequestParts) -> Result { if let Some(auth) = req .headers() - .expect("headers already extracted") .get("authorization") .and_then(|v| v.to_str().ok()) { diff --git a/axum/src/extract/form.rs b/axum/src/extract/form.rs index 84d517df80..e53d722ad2 100644 --- a/axum/src/extract/form.rs +++ b/axum/src/extract/form.rs @@ -60,7 +60,7 @@ where .map_err(FailedToDeserializeQueryString::new::)?; Ok(Form(value)) } else { - if !has_content_type(req, &mime::APPLICATION_WWW_FORM_URLENCODED)? { + if !has_content_type(req, &mime::APPLICATION_WWW_FORM_URLENCODED) { return Err(InvalidFormContentType.into()); } diff --git a/axum/src/extract/matched_path.rs b/axum/src/extract/matched_path.rs index 9ef519d158..d98e4f439a 100644 --- a/axum/src/extract/matched_path.rs +++ b/axum/src/extract/matched_path.rs @@ -70,11 +70,8 @@ where type Rejection = MatchedPathRejection; async fn from_request(req: &mut RequestParts) -> Result { - let extensions = req.extensions().ok_or_else(|| { - MatchedPathRejection::ExtensionsAlreadyExtracted(ExtensionsAlreadyExtracted::default()) - })?; - - let matched_path = extensions + let matched_path = req + .extensions() .get::() .ok_or(MatchedPathRejection::MatchedPathMissing(MatchedPathMissing))? .clone(); diff --git a/axum/src/extract/mod.rs b/axum/src/extract/mod.rs index 4fdd0ba095..8e4c8e99c6 100644 --- a/axum/src/extract/mod.rs +++ b/axum/src/extract/mod.rs @@ -15,7 +15,6 @@ pub mod ws; mod content_length_limit; mod extension; mod form; -mod matched_path; mod query; mod raw_query; mod request_parts; @@ -31,17 +30,23 @@ pub use self::{ extension::Extension, extractor_middleware::extractor_middleware, form::Form, - matched_path::MatchedPath, path::Path, query::Query, raw_query::RawQuery, - request_parts::OriginalUri, request_parts::{BodyStream, RawBody}, }; #[doc(no_inline)] #[cfg(feature = "json")] pub use crate::Json; +#[cfg(feature = "matched-path")] +mod matched_path; + +#[cfg(feature = "matched-path")] +#[cfg_attr(docsrs, doc(cfg(feature = "matched-path")))] +#[doc(inline)] +pub use self::matched_path::MatchedPath; + #[cfg(feature = "multipart")] #[cfg_attr(docsrs, doc(cfg(feature = "multipart")))] pub mod multipart; @@ -51,6 +56,11 @@ pub mod multipart; #[doc(inline)] pub use self::multipart::Multipart; +#[cfg(feature = "original-uri")] +#[cfg_attr(docsrs, doc(cfg(feature = "original-uri")))] +#[doc(inline)] +pub use self::request_parts::OriginalUri; + #[cfg(feature = "ws")] #[cfg_attr(docsrs, doc(cfg(feature = "ws")))] #[doc(inline)] @@ -68,24 +78,20 @@ pub use self::typed_header::TypedHeader; pub(crate) fn has_content_type( req: &RequestParts, expected_content_type: &mime::Mime, -) -> Result { - let content_type = if let Some(content_type) = req - .headers() - .ok_or_else(HeadersAlreadyExtracted::default)? - .get(header::CONTENT_TYPE) - { +) -> bool { + let content_type = if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) { content_type } else { - return Ok(false); + return false; }; let content_type = if let Ok(content_type) = content_type.to_str() { content_type } else { - return Ok(false); + return false; }; - Ok(content_type.starts_with(expected_content_type.as_ref())) + content_type.starts_with(expected_content_type.as_ref()) } pub(crate) fn take_body(req: &mut RequestParts) -> Result { diff --git a/axum/src/extract/multipart.rs b/axum/src/extract/multipart.rs index c3366ee04e..26138149ca 100644 --- a/axum/src/extract/multipart.rs +++ b/axum/src/extract/multipart.rs @@ -8,14 +8,13 @@ use crate::BoxError; use async_trait::async_trait; use futures_util::stream::Stream; use http::header::{HeaderMap, CONTENT_TYPE}; -use mime::Mime; use std::{ fmt, pin::Pin, task::{Context, Poll}, }; -/// Extractor that parses `multipart/form-data` requests commonly used with file uploads. +/// Extractor that parses `multipart/form-data` requests (commonly used with file uploads). /// /// # Example /// @@ -42,7 +41,7 @@ use std::{ /// # }; /// ``` /// -/// For security reasons its recommended to combine this with +/// For security reasons it's recommended to combine this with /// [`ContentLengthLimit`](super::ContentLengthLimit) to limit the size of the request payload. #[derive(Debug)] pub struct Multipart { @@ -59,7 +58,7 @@ where async fn from_request(req: &mut RequestParts) -> Result { let stream = BodyStream::from_request(req).await?; - let headers = req.headers().ok_or_else(HeadersAlreadyExtracted::default)?; + let headers = req.headers(); let boundary = parse_boundary(headers).ok_or(InvalidBoundary)?; let multipart = multer::Multipart::new(stream, boundary); Ok(Self { inner: multipart }) @@ -120,9 +119,9 @@ impl<'a> Field<'a> { self.inner.file_name() } - /// Get the content type of the field. - pub fn content_type(&self) -> Option<&Mime> { - self.inner.content_type() + /// Get the [content type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) of the field. + pub fn content_type(&self) -> Option<&str> { + self.inner.content_type().map(|m| m.as_ref()) } /// Get a map of headers as [`HeaderMap`]. @@ -180,7 +179,6 @@ composite_rejection! { pub enum MultipartRejection { BodyAlreadyExtracted, InvalidBoundary, - HeadersAlreadyExtracted, } } @@ -191,3 +189,40 @@ define_rejection! { /// missing or invalid. pub struct InvalidBoundary; } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{response::IntoResponse, routing::post, test_helpers::*, Router}; + + #[tokio::test] + async fn content_type_with_encoding() { + const BYTES: &[u8] = "🦀".as_bytes(); + const FILE_NAME: &str = "index.html"; + const CONTENT_TYPE: &str = "text/html; charset=utf-8"; + + async fn handle(mut multipart: Multipart) -> impl IntoResponse { + let field = multipart.next_field().await.unwrap().unwrap(); + + assert_eq!(field.file_name().unwrap(), FILE_NAME); + assert_eq!(field.content_type().unwrap(), CONTENT_TYPE); + assert_eq!(field.bytes().await.unwrap(), BYTES); + + assert!(multipart.next_field().await.unwrap().is_none()); + } + + let app = Router::new().route("/", post(handle)); + + let client = TestClient::new(app); + + let form = reqwest::multipart::Form::new().part( + "file", + reqwest::multipart::Part::bytes(BYTES) + .file_name(FILE_NAME) + .mime_str(CONTENT_TYPE) + .unwrap(), + ); + + client.post("/").multipart(form).send().await; + } +} diff --git a/axum/src/extract/path/mod.rs b/axum/src/extract/path/mod.rs index de9af83df7..38a2a7b160 100644 --- a/axum/src/extract/path/mod.rs +++ b/axum/src/extract/path/mod.rs @@ -3,7 +3,6 @@ mod de; -use super::rejection::ExtensionsAlreadyExtracted; use crate::{ body::{boxed, Full}, extract::{rejection::*, FromRequest, RequestParts}, @@ -164,11 +163,7 @@ where type Rejection = PathRejection; async fn from_request(req: &mut RequestParts) -> Result { - let ext = req - .extensions_mut() - .ok_or_else::(|| ExtensionsAlreadyExtracted::default().into())?; - - let params = match ext.get::>() { + let params = match req.extensions_mut().get::>() { Some(Some(UrlParams(Ok(params)))) => Cow::Borrowed(params), Some(Some(UrlParams(Err(InvalidUtf8InPathParam { key })))) => { let err = PathDeserializationError { @@ -519,6 +514,9 @@ mod tests { let res = client.get("/foo").send().await; assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(res.text().await, "Extensions taken by other extractor"); + assert_eq!( + res.text().await, + "No paths parameters found for matched route. Are you also extracting `Request<_>`?" + ); } } diff --git a/axum/src/extract/rejection.rs b/axum/src/extract/rejection.rs index 2777a64fca..96002c0815 100644 --- a/axum/src/extract/rejection.rs +++ b/axum/src/extract/rejection.rs @@ -52,9 +52,10 @@ define_rejection! { define_rejection! { #[status = INTERNAL_SERVER_ERROR] - #[body = "No paths parameters found for matched route. This is a bug in axum. Please open an issue"] - /// Rejection type used if axum's internal representation of path parameters is missing. This - /// should never happen and is a bug in axum if it does. + #[body = "No paths parameters found for matched route. Are you also extracting `Request<_>`?"] + /// Rejection type used if axum's internal representation of path parameters + /// is missing. This is commonly caused by extracting `Request<_>`. `Path` + /// must be extracted first. pub struct MissingPathParams; } @@ -124,7 +125,6 @@ composite_rejection! { InvalidFormContentType, FailedToDeserializeQueryString, BytesRejection, - HeadersAlreadyExtracted, } } @@ -139,7 +139,6 @@ composite_rejection! { InvalidJsonBody, MissingJsonContentType, BytesRejection, - HeadersAlreadyExtracted, } } @@ -150,7 +149,6 @@ composite_rejection! { /// can fail. pub enum ExtensionRejection { MissingExtension, - ExtensionsAlreadyExtracted, } } @@ -162,7 +160,6 @@ composite_rejection! { pub enum PathRejection { FailedToDeserializePathParams, MissingPathParams, - ExtensionsAlreadyExtracted, } } @@ -178,7 +175,6 @@ define_rejection! { composite_rejection! { /// Rejection used for [`MatchedPath`](super::MatchedPath). pub enum MatchedPathRejection { - ExtensionsAlreadyExtracted, MatchedPathMissing, } } @@ -195,8 +191,6 @@ pub enum ContentLengthLimitRejection { #[allow(missing_docs)] LengthRequired(LengthRequired), #[allow(missing_docs)] - HeadersAlreadyExtracted(HeadersAlreadyExtracted), - #[allow(missing_docs)] Inner(T), } @@ -208,7 +202,6 @@ where match self { Self::PayloadTooLarge(inner) => inner.into_response(), Self::LengthRequired(inner) => inner.into_response(), - Self::HeadersAlreadyExtracted(inner) => inner.into_response(), Self::Inner(inner) => inner.into_response(), } } @@ -222,7 +215,6 @@ where match self { Self::PayloadTooLarge(inner) => inner.fmt(f), Self::LengthRequired(inner) => inner.fmt(f), - Self::HeadersAlreadyExtracted(inner) => inner.fmt(f), Self::Inner(inner) => inner.fmt(f), } } @@ -236,7 +228,6 @@ where match self { Self::PayloadTooLarge(inner) => Some(inner), Self::LengthRequired(inner) => Some(inner), - Self::HeadersAlreadyExtracted(inner) => Some(inner), Self::Inner(inner) => Some(inner), } } diff --git a/axum/src/extract/request_parts.rs b/axum/src/extract/request_parts.rs index 6764bdc3c9..7994caf83a 100644 --- a/axum/src/extract/request_parts.rs +++ b/axum/src/extract/request_parts.rs @@ -80,9 +80,11 @@ use sync_wrapper::SyncWrapper; /// # axum::Server::bind(&"".parse().unwrap()).serve(app.into_make_service()).await.unwrap(); /// # }; /// ``` +#[cfg(feature = "original-uri")] #[derive(Debug, Clone)] pub struct OriginalUri(pub Uri); +#[cfg(feature = "original-uri")] #[async_trait] impl FromRequest for OriginalUri where diff --git a/axum/src/extract/typed_header.rs b/axum/src/extract/typed_header.rs index 0151cfe2e4..7bb52c5324 100644 --- a/axum/src/extract/typed_header.rs +++ b/axum/src/extract/typed_header.rs @@ -44,16 +44,7 @@ where type Rejection = TypedHeaderRejection; async fn from_request(req: &mut RequestParts) -> Result { - let headers = if let Some(headers) = req.headers() { - headers - } else { - return Err(TypedHeaderRejection { - name: T::name(), - reason: TypedHeaderRejectionReason::Missing, - }); - }; - - match headers.typed_try_get::() { + match req.headers().typed_try_get::() { Ok(Some(value)) => Ok(Self(value)), Ok(None) => Err(TypedHeaderRejection { name: T::name(), @@ -98,6 +89,7 @@ impl TypedHeaderRejection { /// Additional information regarding a [`TypedHeaderRejection`](super::TypedHeaderRejection) #[derive(Debug)] +#[non_exhaustive] pub enum TypedHeaderRejectionReason { /// The header was missing from the HTTP request Missing, diff --git a/axum/src/extract/ws.rs b/axum/src/extract/ws.rs index 880ab1e23c..8079be1afd 100644 --- a/axum/src/extract/ws.rs +++ b/axum/src/extract/ws.rs @@ -64,7 +64,7 @@ //! [`StreamExt::split`]: https://docs.rs/futures/0.3.17/futures/stream/trait.StreamExt.html#method.split use self::rejection::*; -use super::{rejection::*, FromRequest, RequestParts}; +use super::{FromRequest, RequestParts}; use crate::{ body::{self, Bytes}, response::Response, @@ -201,7 +201,7 @@ impl WebSocketUpgrade { pub fn on_upgrade(self, callback: F) -> Response where F: FnOnce(WebSocket) -> Fut + Send + 'static, - Fut: Future + Send + 'static, + Fut: Future + Send + 'static, { let on_upgrade = self.on_upgrade; let config = self.config; @@ -249,39 +249,28 @@ where return Err(MethodNotGet.into()); } - if !header_contains(req, header::CONNECTION, "upgrade")? { + if !header_contains(req, header::CONNECTION, "upgrade") { return Err(InvalidConnectionHeader.into()); } - if !header_eq(req, header::UPGRADE, "websocket")? { + if !header_eq(req, header::UPGRADE, "websocket") { return Err(InvalidUpgradeHeader.into()); } - if !header_eq(req, header::SEC_WEBSOCKET_VERSION, "13")? { + if !header_eq(req, header::SEC_WEBSOCKET_VERSION, "13") { return Err(InvalidWebSocketVersionHeader.into()); } - let sec_websocket_key = if let Some(key) = req - .headers_mut() - .ok_or_else(HeadersAlreadyExtracted::default)? - .remove(header::SEC_WEBSOCKET_KEY) - { - key - } else { - return Err(WebSocketKeyHeaderMissing.into()); - }; - - let on_upgrade = req - .extensions_mut() - .ok_or_else(ExtensionsAlreadyExtracted::default)? - .remove::() - .unwrap(); - - let sec_websocket_protocol = req - .headers() - .ok_or_else(HeadersAlreadyExtracted::default)? - .get(header::SEC_WEBSOCKET_PROTOCOL) - .cloned(); + let sec_websocket_key = + if let Some(key) = req.headers_mut().remove(header::SEC_WEBSOCKET_KEY) { + key + } else { + return Err(WebSocketKeyHeaderMissing.into()); + }; + + let on_upgrade = req.extensions_mut().remove::().unwrap(); + + let sec_websocket_protocol = req.headers().get(header::SEC_WEBSOCKET_PROTOCOL).cloned(); Ok(Self { config: Default::default(), @@ -293,41 +282,25 @@ where } } -fn header_eq( - req: &RequestParts, - key: HeaderName, - value: &'static str, -) -> Result { - if let Some(header) = req - .headers() - .ok_or_else(HeadersAlreadyExtracted::default)? - .get(&key) - { - Ok(header.as_bytes().eq_ignore_ascii_case(value.as_bytes())) +fn header_eq(req: &RequestParts, key: HeaderName, value: &'static str) -> bool { + if let Some(header) = req.headers().get(&key) { + header.as_bytes().eq_ignore_ascii_case(value.as_bytes()) } else { - Ok(false) + false } } -fn header_contains( - req: &RequestParts, - key: HeaderName, - value: &'static str, -) -> Result { - let header = if let Some(header) = req - .headers() - .ok_or_else(HeadersAlreadyExtracted::default)? - .get(&key) - { +fn header_contains(req: &RequestParts, key: HeaderName, value: &'static str) -> bool { + let header = if let Some(header) = req.headers().get(&key) { header } else { - return Ok(false); + return false; }; if let Ok(header) = std::str::from_utf8(header.as_bytes()) { - Ok(header.to_ascii_lowercase().contains(value)) + header.to_ascii_lowercase().contains(value) } else { - Ok(false) + false } } @@ -537,8 +510,6 @@ fn sign(key: &[u8]) -> HeaderValue { pub mod rejection { //! WebSocket specific rejections. - use crate::extract::rejection::*; - define_rejection! { #[status = METHOD_NOT_ALLOWED] #[body = "Request method must be `GET`"] @@ -585,8 +556,6 @@ pub mod rejection { InvalidUpgradeHeader, InvalidWebSocketVersionHeader, WebSocketKeyHeaderMissing, - HeadersAlreadyExtracted, - ExtensionsAlreadyExtracted, } } } diff --git a/axum/src/json.rs b/axum/src/json.rs index 103e2997ba..3b347250c6 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -96,7 +96,7 @@ where type Rejection = JsonRejection; async fn from_request(req: &mut RequestParts) -> Result { - if json_content_type(req)? { + if json_content_type(req) { let bytes = Bytes::from_request(req).await?; let value = serde_json::from_slice(&bytes).map_err(InvalidJsonBody::from_err)?; @@ -108,33 +108,29 @@ where } } -fn json_content_type(req: &RequestParts) -> Result { - let content_type = if let Some(content_type) = req - .headers() - .ok_or_else(HeadersAlreadyExtracted::default)? - .get(header::CONTENT_TYPE) - { +fn json_content_type(req: &RequestParts) -> bool { + let content_type = if let Some(content_type) = req.headers().get(header::CONTENT_TYPE) { content_type } else { - return Ok(false); + return false; }; let content_type = if let Ok(content_type) = content_type.to_str() { content_type } else { - return Ok(false); + return false; }; let mime = if let Ok(mime) = content_type.parse::() { mime } else { - return Ok(false); + return false; }; let is_json_content_type = mime.type_() == "application" && (mime.subtype() == "json" || mime.suffix().map_or(false, |name| name == "json")); - Ok(is_json_content_type) + is_json_content_type } impl Deref for Json { diff --git a/axum/src/lib.rs b/axum/src/lib.rs index e7cdb9fcca..29ea6a177d 100644 --- a/axum/src/lib.rs +++ b/axum/src/lib.rs @@ -375,12 +375,16 @@ //! `http1` | Enables hyper's `http1` feature | Yes //! `http2` | Enables hyper's `http2` feature | No //! `json` | Enables the [`Json`] type and some similar convenience functionality | Yes +//! `matched-path` | Enables capturing of every request's router path and the [`MatchedPath`] extractor | Yes //! `multipart` | Enables parsing `multipart/form-data` requests with [`Multipart`] | No +//! `original-uri` | Enables capturing of every request's original URI and the [`OriginalUri`] extractor | Yes //! `tower-log` | Enables `tower`'s `log` feature | Yes //! `ws` | Enables WebSockets support via [`extract::ws`] | No //! //! [`TypedHeader`]: crate::extract::TypedHeader +//! [`MatchedPath`]: crate::extract::MatchedPath //! [`Multipart`]: crate::extract::Multipart +//! [`OriginalUri`]: crate::extract::OriginalUri //! [`tower`]: https://crates.io/crates/tower //! [`tower-http`]: https://crates.io/crates/tower-http //! [`tokio`]: http://crates.io/crates/tokio @@ -392,7 +396,6 @@ //! [examples]: https://github.com/tokio-rs/axum/tree/main/examples //! [`Router::merge`]: crate::routing::Router::merge //! [`axum::Server`]: hyper::server::Server -//! [`OriginalUri`]: crate::extract::OriginalUri //! [`Service`]: tower::Service //! [`Service::poll_ready`]: tower::Service::poll_ready //! [`Service`'s]: tower::Service diff --git a/axum/src/response/sse.rs b/axum/src/response/sse.rs index c5b6f7376d..f1de75b05a 100644 --- a/axum/src/response/sse.rs +++ b/axum/src/response/sse.rs @@ -32,15 +32,14 @@ use crate::{ response::{IntoResponse, Response}, BoxError, }; +use bytes::{BufMut, BytesMut}; use futures_util::{ ready, stream::{Stream, TryStream}, }; use pin_project_lite::pin_project; use std::{ - borrow::Cow, fmt, - fmt::Write, future::Future, pin::Pin, task::{Context, Poll}, @@ -134,9 +133,7 @@ where match this.event_stream.get_pin_mut().poll_next(cx) { Poll::Pending => { if let Some(keep_alive) = this.keep_alive.as_pin_mut() { - keep_alive - .poll_event(cx) - .map(|e| Some(Ok(Bytes::from(e.to_string())))) + keep_alive.poll_event(cx).map(|e| Some(Ok(e))) } else { Poll::Pending } @@ -145,7 +142,7 @@ where if let Some(keep_alive) = this.keep_alive.as_pin_mut() { keep_alive.reset(); } - Poll::Ready(Some(Ok(Bytes::from(event.to_string())))) + Poll::Ready(Some(Ok(event.finalize()))) } Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error))), Poll::Ready(None) => Poll::Ready(None), @@ -161,21 +158,10 @@ where } /// Server-sent event -#[derive(Default, Debug)] +#[derive(Debug, Default, Clone)] pub struct Event { - id: Option, - data: Option, - event: Option, - comment: Option, - retry: Option, -} - -// Server-sent event data type -#[derive(Debug)] -enum DataType { - Text(String), - #[cfg(feature = "json")] - Json(String), + buffer: BytesMut, + flags: EventFlags, } impl Event { @@ -189,18 +175,22 @@ impl Event { /// /// # Panics /// - /// Panics if `data` contains any carriage returns, as they cannot be transmitted over SSE. + /// - Panics if `data` contains any carriage returns, as they cannot be transmitted over SSE. + /// - Panics if `data` or `json_data` have already been called. pub fn data(mut self, data: T) -> Event where - T: Into, + T: AsRef, { - let data = data.into(); - assert_eq!( - memchr::memchr(b'\r', data.as_bytes()), - None, - "SSE data cannot contain carriage returns", - ); - self.data = Some(DataType::Text(data)); + if self.flags.contains(EventFlags::HAS_DATA) { + panic!("Called `EventBuilder::data` multiple times"); + } + + for line in memchr_split(b'\n', data.as_ref().as_bytes()) { + self.field("data", line); + } + + self.flags.insert(EventFlags::HAS_DATA); + self } @@ -209,13 +199,26 @@ impl Event { /// This corresponds to [`MessageEvent`'s data field]. /// /// [`MessageEvent`'s data field]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data + /// + /// # Panics + /// + /// Panics if `data` or `json_data` have already been called. #[cfg(feature = "json")] #[cfg_attr(docsrs, doc(cfg(feature = "json")))] - pub fn json_data(mut self, data: T) -> Result + pub fn json_data(mut self, data: T) -> serde_json::Result where T: serde::Serialize, { - self.data = Some(DataType::Json(serde_json::to_string(&data)?)); + if self.flags.contains(EventFlags::HAS_DATA) { + panic!("Called `EventBuilder::json_data` multiple times"); + } + + self.buffer.extend_from_slice(b"data:"); + serde_json::to_writer((&mut self.buffer).writer(), &data)?; + self.buffer.put_u8(b'\n'); + + self.flags.insert(EventFlags::HAS_DATA); + Ok(self) } @@ -223,21 +226,17 @@ impl Event { /// /// This field will be ignored by most SSE clients. /// + /// Unlike other functions, this function can be called multiple times to add many comments. + /// /// # Panics /// /// Panics if `comment` contains any newlines or carriage returns, as they are not allowed in /// comments. pub fn comment(mut self, comment: T) -> Event where - T: Into, + T: AsRef, { - let comment = comment.into(); - assert_eq!( - memchr::memchr2(b'\r', b'\n', comment.as_bytes()), - None, - "SSE comment cannot contain newlines or carriage returns" - ); - self.comment = Some(comment); + self.field("", comment.as_ref()); self } @@ -253,18 +252,19 @@ impl Event { /// /// # Panics /// - /// Panics if `event` contains any newlines or carriage returns. + /// - Panics if `event` contains any newlines or carriage returns. + /// - Panics if this function has already been called on this event. pub fn event(mut self, event: T) -> Event where - T: Into, + T: AsRef, { - let event = event.into(); - assert_eq!( - memchr::memchr2(b'\r', b'\n', event.as_bytes()), - None, - "SSE event name cannot contain newlines or carriage returns" - ); - self.event = Some(event); + if self.flags.contains(EventFlags::HAS_EVENT) { + panic!("Called `EventBuilder::event` multiple times"); + } + self.flags.insert(EventFlags::HAS_EVENT); + + self.field("event", event.as_ref()); + self } @@ -273,8 +273,40 @@ impl Event { /// This sets how long clients will wait before reconnecting if they are disconnected from the /// SSE endpoint. Note that this is just a hint: clients are free to wait for longer if they /// wish, such as if they implement exponential backoff. + /// + /// # Panics + /// + /// Panics if this function has already been called on this event. pub fn retry(mut self, duration: Duration) -> Event { - self.retry = Some(duration); + if self.flags.contains(EventFlags::HAS_RETRY) { + panic!("Called `EventBuilder::retry` multiple times"); + } + self.flags.insert(EventFlags::HAS_RETRY); + + self.buffer.extend_from_slice(b"retry:"); + + let secs = duration.as_secs(); + let millis = duration.subsec_millis(); + + if secs > 0 { + // format seconds + self.buffer + .extend_from_slice(itoa::Buffer::new().format(secs).as_bytes()); + + // pad milliseconds + if millis < 10 { + self.buffer.extend_from_slice(b"00"); + } else if millis < 100 { + self.buffer.extend_from_slice(b"0"); + } + } + + // format milliseconds + self.buffer + .extend_from_slice(itoa::Buffer::new().format(millis).as_bytes()); + + self.buffer.put_u8(b'\n'); + self } @@ -288,86 +320,58 @@ impl Event { /// /// # Panics /// - /// Panics if `id` contains any newlines, carriage returns or null characters. + /// - Panics if `id` contains any newlines, carriage returns or null characters. + /// - Panics if this function has already been called on this event. pub fn id(mut self, id: T) -> Event where - T: Into, + T: AsRef, { - let id = id.into(); + if self.flags.contains(EventFlags::HAS_ID) { + panic!("Called `EventBuilder::id` multiple times"); + } + self.flags.insert(EventFlags::HAS_ID); + + let id = id.as_ref().as_bytes(); assert_eq!( - memchr::memchr3(b'\r', b'\n', b'\0', id.as_bytes()), + memchr::memchr(b'\0', id), None, - "Event ID cannot contain newlines, carriage returns or null characters", + "Event ID cannot contain null characters", ); - self.id = Some(id); + + self.field("id", id); self } -} - -impl fmt::Display for Event { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if let Some(comment) = &self.comment { - ":".fmt(f)?; - comment.fmt(f)?; - f.write_char('\n')?; - } - - if let Some(event) = &self.event { - "event: ".fmt(f)?; - event.fmt(f)?; - f.write_char('\n')?; - } - match &self.data { - Some(DataType::Text(data)) => { - for line in data.split('\n') { - "data: ".fmt(f)?; - line.fmt(f)?; - f.write_char('\n')?; - } - } - #[cfg(feature = "json")] - Some(DataType::Json(data)) => { - "data:".fmt(f)?; - data.fmt(f)?; - f.write_char('\n')?; - } - None => {} - } - - if let Some(id) = &self.id { - "id: ".fmt(f)?; - id.fmt(f)?; - f.write_char('\n')?; - } - - if let Some(duration) = &self.retry { - "retry:".fmt(f)?; - - let secs = duration.as_secs(); - let millis = duration.subsec_millis(); - - if secs > 0 { - // format seconds - secs.fmt(f)?; - - // pad milliseconds - if millis < 10 { - f.write_str("00")?; - } else if millis < 100 { - f.write_char('0')?; - } - } - - // format milliseconds - millis.fmt(f)?; - - f.write_char('\n')?; + fn field(&mut self, name: &str, value: impl AsRef<[u8]>) { + let value = value.as_ref(); + assert_eq!( + memchr::memchr2(b'\r', b'\n', value), + None, + "SSE field value cannot contain newlines or carriage returns", + ); + self.buffer.extend_from_slice(name.as_bytes()); + self.buffer.put_u8(b':'); + // Prevent values that start with spaces having that space stripped + if value.starts_with(b" ") { + self.buffer.put_u8(b' '); } + self.buffer.extend_from_slice(value); + self.buffer.put_u8(b'\n'); + } - f.write_char('\n')?; + fn finalize(mut self) -> Bytes { + self.buffer.put_u8(b'\n'); + self.buffer.freeze() + } +} - Ok(()) +bitflags::bitflags! { + #[derive(Default)] + struct EventFlags: u8 { + const HAS_DATA = 0b0001; + const HAS_EVENT = 0b0010; + const HAS_RETRY = 0b0100; + const HAS_ID = 0b1000; } } @@ -375,7 +379,7 @@ impl fmt::Display for Event { /// of each message, and the associated stream. #[derive(Debug, Clone)] pub struct KeepAlive { - comment_text: Cow<'static, str>, + event: Bytes, max_interval: Duration, } @@ -383,7 +387,7 @@ impl KeepAlive { /// Create a new `KeepAlive`. pub fn new() -> Self { Self { - comment_text: Cow::Borrowed(""), + event: Bytes::from_static(b":\n\n"), max_interval: Duration::from_secs(15), } } @@ -399,11 +403,17 @@ impl KeepAlive { /// Customize the text of the keep-alive message. /// /// Default is an empty comment. + /// + /// + /// # Panics + /// + /// Panics if `text` contains any newline or carriage returns, as they are not allowed in SSE + /// comments. pub fn text(mut self, text: I) -> Self where - I: Into>, + I: AsRef, { - self.comment_text = text.into(); + self.event = Event::default().comment(text).finalize(); self } } @@ -437,13 +447,12 @@ impl KeepAliveStream { .reset(tokio::time::Instant::now() + this.keep_alive.max_interval); } - fn poll_event(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + fn poll_event(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.as_mut().project(); ready!(this.alive_timer.poll(cx)); - let comment_str = this.keep_alive.comment_text.clone(); - let event = Event::default().comment(comment_str); + let event = this.keep_alive.event.clone(); self.reset(); @@ -451,6 +460,32 @@ impl KeepAliveStream { } } +fn memchr_split(needle: u8, haystack: &[u8]) -> MemchrSplit<'_> { + MemchrSplit { + needle, + haystack: Some(haystack), + } +} + +struct MemchrSplit<'a> { + needle: u8, + haystack: Option<&'a [u8]>, +} + +impl<'a> Iterator for MemchrSplit<'a> { + type Item = &'a [u8]; + fn next(&mut self) -> Option { + let haystack = self.haystack?; + if let Some(pos) = memchr::memchr(self.needle, haystack) { + let (front, back) = haystack.split_at(pos); + self.haystack = Some(&back[1..]); + Some(front) + } else { + self.haystack.take() + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -462,10 +497,10 @@ mod tests { #[test] fn leading_space_is_not_stripped() { let no_leading_space = Event::default().data("\tfoobar"); - assert_eq!(no_leading_space.to_string(), "data: \tfoobar\n\n"); + assert_eq!(&*no_leading_space.finalize(), b"data:\tfoobar\n\n"); let leading_space = Event::default().data(" foobar"); - assert_eq!(leading_space.to_string(), "data: foobar\n\n"); + assert_eq!(&*leading_space.finalize(), b"data: foobar\n\n"); } #[tokio::test] @@ -609,4 +644,32 @@ mod tests { fields } + + #[test] + fn memchr_spliting() { + assert_eq!( + memchr_split(2, &[]).collect::>(), + [&[]] as [&[u8]; 1] + ); + assert_eq!( + memchr_split(2, &[2]).collect::>(), + [&[], &[]] as [&[u8]; 2] + ); + assert_eq!( + memchr_split(2, &[1]).collect::>(), + [&[1]] as [&[u8]; 1] + ); + assert_eq!( + memchr_split(2, &[1, 2]).collect::>(), + [&[1], &[]] as [&[u8]; 2] + ); + assert_eq!( + memchr_split(2, &[2, 1]).collect::>(), + [&[], &[1]] as [&[u8]; 2] + ); + assert_eq!( + memchr_split(2, &[1, 2, 2, 1]).collect::>(), + [&[1], &[], &[1]] as [&[u8]; 3] + ); + } } diff --git a/axum/src/routing/mod.rs b/axum/src/routing/mod.rs index 08c0c37bd8..bf55d4f252 100644 --- a/axum/src/routing/mod.rs +++ b/axum/src/routing/mod.rs @@ -3,13 +3,8 @@ use self::{future::RouterFuture, not_found::NotFound}; use crate::{ body::{boxed, Body, Bytes, HttpBody}, - extract::{ - connect_info::{Connected, IntoMakeServiceWithConnectInfo}, - MatchedPath, OriginalUri, - }, - response::IntoResponse, - response::Redirect, - response::Response, + extract::connect_info::{Connected, IntoMakeServiceWithConnectInfo}, + response::{IntoResponse, Redirect, Response}, routing::strip_prefix::StripPrefix, util::{try_downcast, ByteStr, PercentDecodedByteStr}, BoxError, @@ -405,7 +400,10 @@ where let id = *match_.value; req.extensions_mut().insert(id); + #[cfg(feature = "matched-path")] if let Some(matched_path) = self.node.route_id_to_path.get(&id) { + use crate::extract::MatchedPath; + let matched_path = if let Some(previous) = req.extensions_mut().get::() { // a previous `MatchedPath` might exist if we're inside a nested Router let previous = if let Some(previous) = @@ -476,9 +474,14 @@ where #[inline] fn call(&mut self, mut req: Request) -> Self::Future { - if req.extensions().get::().is_none() { - let original_uri = OriginalUri(req.uri().clone()); - req.extensions_mut().insert(original_uri); + #[cfg(feature = "original-uri")] + { + use crate::extract::OriginalUri; + + if req.extensions().get::().is_none() { + let original_uri = OriginalUri(req.uri().clone()); + req.extensions_mut().insert(original_uri); + } } let path = req.uri().path().to_owned(); diff --git a/axum/src/test_helpers.rs b/axum/src/test_helpers.rs index 554a144be6..7ffdf1a83c 100644 --- a/axum/src/test_helpers.rs +++ b/axum/src/test_helpers.rs @@ -103,6 +103,7 @@ impl RequestBuilder { self.builder = self.builder.json(json); self } + pub(crate) fn header(mut self, key: K, value: V) -> Self where HeaderName: TryFrom, @@ -113,6 +114,11 @@ impl RequestBuilder { self.builder = self.builder.header(key, value); self } + + pub(crate) fn multipart(mut self, form: reqwest::multipart::Form) -> Self { + self.builder = self.builder.multipart(form); + self + } } pub(crate) struct TestResponse { diff --git a/examples/customize-extractor-error/src/main.rs b/examples/customize-extractor-error/src/main.rs index 4e951001d7..2afba46ffa 100644 --- a/examples/customize-extractor-error/src/main.rs +++ b/examples/customize-extractor-error/src/main.rs @@ -73,9 +73,6 @@ where JsonRejection::MissingJsonContentType(err) => { (StatusCode::BAD_REQUEST, err.to_string().into()) } - JsonRejection::HeadersAlreadyExtracted(err) => { - (StatusCode::INTERNAL_SERVER_ERROR, err.to_string().into()) - } err => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Unknown internal error: {}", err).into(), diff --git a/examples/multipart-form/src/main.rs b/examples/multipart-form/src/main.rs index b734e65a5f..6d1edf1ef8 100644 --- a/examples/multipart-form/src/main.rs +++ b/examples/multipart-form/src/main.rs @@ -65,8 +65,16 @@ async fn accept_form( ) { while let Some(field) = multipart.next_field().await.unwrap() { let name = field.name().unwrap().to_string(); + let file_name = field.file_name().unwrap().to_string(); + let content_type = field.content_type().unwrap().to_string(); let data = field.bytes().await.unwrap(); - println!("Length of `{}` is {} bytes", name, data.len()); + println!( + "Length of `{}` (`{}`: `{}`) is {} bytes", + name, + file_name, + content_type, + data.len() + ); } }