diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index 48c6556c..4ba0732a 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -15,6 +15,7 @@ version = "0.9.3" default = ["tracing"] async-read-body = ["dep:tokio-util", "tokio-util?/io", "dep:tokio"] +attachment = ["dep:tracing"] cookie = ["dep:cookie"] cookie-private = ["cookie", "cookie?/private"] cookie-signed = ["cookie", "cookie?/signed"] diff --git a/axum-extra/src/extract/json_deserializer.rs b/axum-extra/src/extract/json_deserializer.rs index b138c50f..03f1a419 100644 --- a/axum-extra/src/extract/json_deserializer.rs +++ b/axum-extra/src/extract/json_deserializer.rs @@ -23,8 +23,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 diff --git a/axum-extra/src/response/attachment.rs b/axum-extra/src/response/attachment.rs new file mode 100644 index 00000000..923ad991 --- /dev/null +++ b/axum-extra/src/response/attachment.rs @@ -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, (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)] +pub struct Attachment { + inner: T, + filename: Option, + content_type: Option, +} + +impl Attachment { + /// 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>(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>(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 IntoResponse for Attachment +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() + } +} diff --git a/axum-extra/src/response/mod.rs b/axum-extra/src/response/mod.rs index dda382cf..d17f7be6 100644 --- a/axum-extra/src/response/mod.rs +++ b/axum-extra/src/response/mod.rs @@ -3,6 +3,9 @@ #[cfg(feature = "erased-json")] mod erased_json; +#[cfg(feature = "attachment")] +mod attachment; + #[cfg(feature = "erased-json")] pub use erased_json::ErasedJson; @@ -10,6 +13,9 @@ pub use erased_json::ErasedJson; #[doc(no_inline)] pub use crate::json_lines::JsonLines; +#[cfg(feature = "attachment")] +pub use attachment::Attachment; + macro_rules! mime_response { ( $(#[$m:meta])* diff --git a/axum-extra/src/routing/typed.rs b/axum-extra/src/routing/typed.rs index f7542823..34c9513b 100644 --- a/axum-extra/src/routing/typed.rs +++ b/axum-extra/src/routing/typed.rs @@ -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: diff --git a/axum/src/boxed.rs b/axum/src/boxed.rs index f541a9fa..32808f51 100644 --- a/axum/src/boxed.rs +++ b/axum/src/boxed.rs @@ -103,6 +103,7 @@ where } } +#[allow(dead_code)] pub(crate) struct MakeErasedRouter { pub(crate) router: Router, pub(crate) into_route: fn(Router, S) -> Route, diff --git a/axum/src/docs/routing/nest.md b/axum/src/docs/routing/nest.md index c3f7308f..3151729e 100644 --- a/axum/src/docs/routing/nest.md +++ b/axum/src/docs/routing/nest.md @@ -181,7 +181,7 @@ router. # Panics - If the route overlaps with another route. See [`Router::route`] -for more details. + for more details. - If the route contains a wildcard (`*`). - If `path` is empty. diff --git a/axum/src/json.rs b/axum/src/json.rs index c4435922..854ead4e 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -17,8 +17,7 @@ use serde::{de::DeserializeOwned, Serialize}; /// /// - The request doesn't have a `Content-Type: application/json` (or similar) header. /// - 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. /// - Buffering the request body fails. /// /// ⚠️ Since parsing JSON requires consuming the request body, the `Json` extractor must be