diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index 67ab58f1..5149cf0a 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning]. # Unreleased -- None. +- **added:** Add `OptionalPath` extractor ([#1889]) + +[#1889]: https://github.com/tokio-rs/axum/pull/1889 # 0.7.2 (22. March, 2023) diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index cef0a8b5..59f20df4 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -19,11 +19,10 @@ cookie = ["dep:cookie"] cookie-private = ["cookie", "cookie?/private"] cookie-signed = ["cookie", "cookie?/signed"] cookie-key-expansion = ["cookie", "cookie?/key-expansion"] -erased-json = ["dep:serde_json", "dep:serde"] -form = ["dep:serde", "dep:serde_html_form"] +erased-json = ["dep:serde_json"] +form = ["dep:serde_html_form"] json-lines = [ "dep:serde_json", - "dep:serde", "dep:tokio-util", "dep:tokio-stream", "tokio-util?/io", @@ -31,8 +30,8 @@ json-lines = [ ] multipart = ["dep:multer"] protobuf = ["dep:prost"] -query = ["dep:serde", "dep:serde_html_form"] -typed-routing = ["dep:axum-macros", "dep:serde", "dep:percent-encoding", "dep:serde_html_form", "dep:form_urlencoded"] +query = ["dep:serde_html_form"] +typed-routing = ["dep:axum-macros", "dep:percent-encoding", "dep:serde_html_form", "dep:form_urlencoded"] [dependencies] axum = { path = "../axum", version = "0.6.9", default-features = false } @@ -42,6 +41,7 @@ http = "0.2" http-body = "0.4.4" mime = "0.3" pin-project-lite = "0.2" +serde = "1.0" tokio = "1.19" tower = { version = "0.4", default_features = false, features = ["util"] } tower-http = { version = "0.4", features = ["map-response-body"] } @@ -55,7 +55,6 @@ form_urlencoded = { version = "1.1.0", optional = true } multer = { version = "2.0.0", optional = true } percent-encoding = { version = "2.1", optional = true } prost = { version = "0.11", optional = true } -serde = { version = "1.0", optional = true } serde_html_form = { version = "0.2.0", optional = true } serde_json = { version = "1.0.71", optional = true } tokio-stream = { version = "0.1.9", optional = true } @@ -63,6 +62,7 @@ tokio-util = { version = "0.7", optional = true } [dev-dependencies] axum = { path = "../axum", version = "0.6.0", features = ["headers"] } +axum-macros = { path = "../axum-macros", version = "0.3.7", features = ["__private"] } http-body = "0.4.4" hyper = "0.14" reqwest = { version = "0.11", default-features = false, features = ["json", "stream", "multipart"] } diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index 3ca7749e..923fe456 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -1,6 +1,8 @@ //! Additional extractors. mod cached; +mod optional_path; +mod with_rejection; #[cfg(feature = "form")] mod form; @@ -14,9 +16,7 @@ mod query; #[cfg(feature = "multipart")] pub mod multipart; -mod with_rejection; - -pub use self::cached::Cached; +pub use self::{cached::Cached, optional_path::OptionalPath, with_rejection::WithRejection}; #[cfg(feature = "cookie")] pub use self::cookie::CookieJar; @@ -39,5 +39,3 @@ pub use self::multipart::Multipart; #[cfg(feature = "json-lines")] #[doc(no_inline)] pub use crate::json_lines::JsonLines; - -pub use self::with_rejection::WithRejection; diff --git a/axum-extra/src/extract/optional_path.rs b/axum-extra/src/extract/optional_path.rs new file mode 100644 index 00000000..ca163450 --- /dev/null +++ b/axum-extra/src/extract/optional_path.rs @@ -0,0 +1,102 @@ +use axum::{ + async_trait, + extract::{path::ErrorKind, rejection::PathRejection, FromRequestParts, Path}, + RequestPartsExt, +}; +use serde::de::DeserializeOwned; + +/// Extractor that extracts path arguments the same way as [`Path`], except if there aren't any. +/// +/// This extractor can be used in place of `Path` when you have two routes that you want to handle +/// in mostly the same way, where one has a path parameter and the other one doesn't. +/// +/// # Example +/// +/// ``` +/// use std::num::NonZeroU32; +/// use axum::{ +/// response::IntoResponse, +/// routing::get, +/// Router, +/// }; +/// use axum_extra::extract::OptionalPath; +/// +/// async fn render_blog(OptionalPath(page): OptionalPath) -> impl IntoResponse { +/// // Convert to u32, default to page 1 if not specified +/// let page = page.map_or(1, |param| param.get()); +/// // ... +/// } +/// +/// let app = Router::new() +/// .route("/blog", get(render_blog)) +/// .route("/blog/:page", get(render_blog)); +/// # let app: Router = app; +/// ``` +#[derive(Debug)] +pub struct OptionalPath(pub Option); + +#[async_trait] +impl FromRequestParts for OptionalPath +where + T: DeserializeOwned + Send + 'static, + S: Send + Sync, +{ + type Rejection = PathRejection; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _: &S, + ) -> Result { + match parts.extract::>().await { + Ok(Path(params)) => Ok(Self(Some(params))), + Err(PathRejection::FailedToDeserializePathParams(e)) + if matches!(e.kind(), ErrorKind::WrongNumberOfParameters { got: 0, .. }) => + { + Ok(Self(None)) + } + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use axum::{routing::get, Router}; + + use super::OptionalPath; + use crate::test_helpers::TestClient; + + #[crate::test] + async fn supports_128_bit_numbers() { + async fn handle(OptionalPath(param): OptionalPath) -> String { + let num = param.map_or(0, |p| p.get()); + format!("Success: {num}") + } + + let app = Router::new() + .route("/", get(handle)) + .route("/:num", get(handle)); + + let client = TestClient::new(app); + + let res = client.get("/").send().await; + assert_eq!(res.text().await, "Success: 0"); + + let res = client.get("/1").send().await; + assert_eq!(res.text().await, "Success: 1"); + + let res = client.get("/0").send().await; + assert_eq!( + res.text().await, + "Invalid URL: invalid value: integer `0`, expected a nonzero u32" + ); + + let res = client.get("/NaN").send().await; + assert_eq!( + res.text().await, + "Invalid URL: Cannot parse `\"NaN\"` to a `u32`" + ); + } +} diff --git a/axum-extra/src/lib.rs b/axum-extra/src/lib.rs index 9391fcd1..a7402f21 100644 --- a/axum-extra/src/lib.rs +++ b/axum-extra/src/lib.rs @@ -97,6 +97,9 @@ pub mod __private { pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%'); } +#[cfg(test)] +use axum_macros::__private_axum_test as test; + #[cfg(test)] pub(crate) mod test_helpers { #![allow(unused_imports)] diff --git a/axum/src/extract/path/mod.rs b/axum/src/extract/path/mod.rs index 088ed068..93a039dd 100644 --- a/axum/src/extract/path/mod.rs +++ b/axum/src/extract/path/mod.rs @@ -266,7 +266,8 @@ impl std::error::Error for PathDeserializationError {} /// The kinds of errors that can happen we deserializing into a [`Path`]. /// -/// This type is obtained through [`FailedToDeserializePathParams::into_kind`] and is useful for building +/// This type is obtained through [`FailedToDeserializePathParams::kind`] or +/// [`FailedToDeserializePathParams::into_kind`] and is useful for building /// more precise error messages. #[derive(Debug, PartialEq, Eq)] #[non_exhaustive] @@ -380,6 +381,11 @@ impl fmt::Display for ErrorKind { pub struct FailedToDeserializePathParams(PathDeserializationError); impl FailedToDeserializePathParams { + /// Get a reference to the underlying error kind. + pub fn kind(&self) -> &ErrorKind { + &self.0.kind + } + /// Convert this error into the underlying error kind. pub fn into_kind(self) -> ErrorKind { self.0.kind