From b4204e223dc96df3d51fc8893f32d37b66be7005 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sat, 11 Feb 2023 23:10:07 +0100 Subject: [PATCH] Add `TypedPath::with_query_params` (#1744) --- axum-extra/CHANGELOG.md | 4 +- axum-extra/Cargo.toml | 3 +- axum-extra/src/lib.rs | 3 + axum-extra/src/routing/mod.rs | 2 + axum-extra/src/routing/typed.rs | 142 +++++++++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 3 deletions(-) diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index 980b91e9..71530bc1 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 `TypedPath::with_query_params` ([#1744]) + +[#1744]: https://github.com/tokio-rs/axum/pull/1744 # 0.4.2 (02. December, 2022) diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index 938ea056..9cf797be 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -32,7 +32,7 @@ json-lines = [ protobuf = ["dep:prost"] query = ["dep:serde", "dep:serde_html_form"] spa = ["tower-http/fs"] -typed-routing = ["dep:axum-macros", "dep:serde", "dep:percent-encoding"] +typed-routing = ["dep:axum-macros", "dep:serde", "dep:percent-encoding", "dep:serde_html_form", "dep:form_urlencoded"] [dependencies] axum = { path = "../axum", version = "0.6.0", default-features = false } @@ -50,6 +50,7 @@ tower-service = "0.3" # optional dependencies axum-macros = { path = "../axum-macros", version = "0.3.1", optional = true } cookie = { package = "cookie", version = "0.16", features = ["percent-encode"], optional = true } +form_urlencoded = { version = "1.1.0", optional = true } percent-encoding = { version = "2.1", optional = true } prost = { version = "0.11", optional = true } serde = { version = "1.0", optional = true } diff --git a/axum-extra/src/lib.rs b/axum-extra/src/lib.rs index 2bbb0720..d978c17f 100644 --- a/axum-extra/src/lib.rs +++ b/axum-extra/src/lib.rs @@ -65,6 +65,9 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(test, allow(clippy::float_cmp))] +#[allow(unused_extern_crates)] +extern crate self as axum_extra; + pub mod body; pub mod either; pub mod extract; diff --git a/axum-extra/src/routing/mod.rs b/axum-extra/src/routing/mod.rs index 588618a6..2eef0bc6 100644 --- a/axum-extra/src/routing/mod.rs +++ b/axum-extra/src/routing/mod.rs @@ -20,6 +20,8 @@ mod typed; pub use self::resource::Resource; +#[cfg(feature = "typed-routing")] +pub use self::typed::WithQueryParams; #[cfg(feature = "typed-routing")] pub use axum_macros::TypedPath; diff --git a/axum-extra/src/routing/typed.rs b/axum-extra/src/routing/typed.rs index aeb9936c..8cb9e348 100644 --- a/axum-extra/src/routing/typed.rs +++ b/axum-extra/src/routing/typed.rs @@ -1,5 +1,8 @@ +use std::{any::type_name, fmt}; + use super::sealed::Sealed; use http::Uri; +use serde::Serialize; /// A type safe path. /// @@ -219,7 +222,7 @@ pub trait TypedPath: std::fmt::Display { /// /// # Panics /// - /// The default implementation parses the required [`Display`] implemetation. If that fails it + /// The default implementation parses the required [`Display`] implementation. If that fails it /// will panic. /// /// Using `#[derive(TypedPath)]` will never result in a panic since it percent-encodes @@ -229,6 +232,90 @@ pub trait TypedPath: std::fmt::Display { fn to_uri(&self) -> Uri { self.to_string().parse().unwrap() } + + /// Add query parameters to a path. + /// + /// # Example + /// + /// ``` + /// use axum_extra::routing::TypedPath; + /// use serde::Serialize; + /// + /// #[derive(TypedPath)] + /// #[typed_path("/users")] + /// struct Users; + /// + /// #[derive(Serialize)] + /// struct Pagination { + /// page: u32, + /// per_page: u32, + /// } + /// + /// let path = Users.with_query_params(Pagination { + /// page: 1, + /// per_page: 10, + /// }); + /// + /// assert_eq!(path.to_uri(), "/users?&page=1&per_page=10"); + /// ``` + /// + /// # Panics + /// + /// If `params` doesn't support being serialized as query params [`WithQueryParams`]'s [`Display`] + /// implementation will panic, and thus [`WithQueryParams::to_uri`] will also panic. + /// + /// [`WithQueryParams::to_uri`]: TypedPath::to_uri + /// [`Display`]: std::fmt::Display + fn with_query_params(self, params: T) -> WithQueryParams + where + T: Serialize, + Self: Sized, + { + WithQueryParams { path: self, params } + } +} + +/// A [`TypedPath`] with query params. +/// +/// See [`TypedPath::with_query_params`] for more details. +#[derive(Debug, Clone, Copy)] +pub struct WithQueryParams { + path: P, + params: T, +} + +impl fmt::Display for WithQueryParams +where + P: TypedPath, + T: Serialize, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut out = self.path.to_string(); + if !out.contains('?') { + out.push('?'); + } + let mut urlencoder = form_urlencoded::Serializer::new(&mut out); + self.params + .serialize(serde_html_form::ser::Serializer::new(&mut urlencoder)) + .unwrap_or_else(|err| { + panic!( + "failed to URL encode value of type `{}`: {}", + type_name::(), + err + ) + }); + f.write_str(&out)?; + + Ok(()) + } +} + +impl TypedPath for WithQueryParams +where + P: TypedPath, + T: Serialize, +{ + const PATH: &'static str = P::PATH; } /// Utility trait used with [`RouterExt`] to ensure the second element of a tuple type is a @@ -295,3 +382,56 @@ impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13); impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14); impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15); impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16); + +#[cfg(test)] +mod tests { + use super::*; + use crate::routing::TypedPath; + use serde::Deserialize; + + #[derive(TypedPath, Deserialize)] + #[typed_path("/users/:id")] + struct UsersShow { + id: i32, + } + + #[derive(Serialize)] + struct Params { + foo: &'static str, + bar: i32, + baz: bool, + } + + #[test] + fn with_params() { + let path = UsersShow { id: 1 }.with_query_params(Params { + foo: "foo", + bar: 123, + baz: true, + }); + + let uri = path.to_uri(); + + // according to [the spec] starting the params with `?&` is allowed specifically: + // + // > If bytes is the empty byte sequence, then continue. + // + // [the spec]: https://url.spec.whatwg.org/#urlencoded-parsing + assert_eq!(uri, "/users/1?&foo=foo&bar=123&baz=true"); + } + + #[test] + fn with_params_called_multiple_times() { + let path = UsersShow { id: 1 } + .with_query_params(Params { + foo: "foo", + bar: 123, + baz: true, + }) + .with_query_params([("qux", 1337)]); + + let uri = path.to_uri(); + + assert_eq!(uri, "/users/1?&foo=foo&bar=123&baz=true&qux=1337"); + } +}