From 1499f4fca992c55ae704d274bfb372e617034409 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Mon, 9 May 2022 00:16:30 +0200 Subject: [PATCH 1/8] Support customizing rejections for `#[derive(TypedPath)]` --- axum-extra/src/routing/typed.rs | 73 ++++++++++ axum-macros/src/typed_path.rs | 132 +++++++++++++++--- .../typed_path/pass/customize_rejection.rs | 47 +++++++ 3 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 axum-macros/tests/typed_path/pass/customize_rejection.rs diff --git a/axum-extra/src/routing/typed.rs b/axum-extra/src/routing/typed.rs index 745ed950c5..e8ea2bf6a7 100644 --- a/axum-extra/src/routing/typed.rs +++ b/axum-extra/src/routing/typed.rs @@ -140,12 +140,85 @@ use http::Uri; /// ); /// ``` /// +/// ## Customizing the rejection +/// +/// By default the rejection used in the [`FromRequest`] implemetation will be [`PathRejection`]. +/// +/// That can be customized using `#[typed_path("...", rejection(YourType))]`: +/// +/// ``` +/// use serde::Deserialize; +/// use axum_extra::routing::TypedPath; +/// use axum::{ +/// response::{IntoResponse, Response}, +/// extract::rejection::PathRejection, +/// }; +/// +/// #[derive(TypedPath, Deserialize)] +/// #[typed_path("/users/:id", rejection(UsersMemberRejection))] +/// struct UsersMember { +/// id: String, +/// } +/// +/// struct UsersMemberRejection; +/// +/// // Your rejection type must implement `From`. +/// // +/// // Here you can grab whatever details from the inner rejection +/// // that you need +/// impl From for UsersMemberRejection { +/// fn from(rejection: PathRejection) -> Self { +/// # UsersMemberRejection +/// // ... +/// } +/// } +/// +/// // And your rejection must implement `IntoResponse`, like all rejections +/// impl IntoResponse for UsersMemberRejection { +/// fn into_response(self) -> Response { +/// # ().into_response() +/// // ... +/// } +/// } +/// ``` +/// +/// The `From` requirement only applies if your typed path is a struct with named +/// fields or a tuple struct. For unit structs your rejection type must implement `Default`: +/// +/// ``` +/// use axum_extra::routing::TypedPath; +/// use axum::response::{IntoResponse, Response}; +/// +/// #[derive(TypedPath)] +/// #[typed_path("/users", rejection(UsersCollectionRejection))] +/// struct UsersCollection; +/// +/// struct UsersCollectionRejection; +/// +/// // Since there are no path params the rejection isn't created via `From` but +/// // instead via `Default` +/// impl Default for UsersCollectionRejection { +/// fn default() -> Self { +/// # UsersCollectionRejection +/// // ... +/// } +/// } +/// +/// impl IntoResponse for UsersCollectionRejection { +/// fn into_response(self) -> Response { +/// # ().into_response() +/// // ... +/// } +/// } +/// ``` +/// /// [`FromRequest`]: axum::extract::FromRequest /// [`RouterExt::typed_get`]: super::RouterExt::typed_get /// [`RouterExt::typed_post`]: super::RouterExt::typed_post /// [`Path`]: axum::extract::Path /// [`Display`]: std::fmt::Display /// [`Deserialize`]: serde::Deserialize +/// [`PathRejection`]: axum::extract::rejection::PathRejection pub trait TypedPath: std::fmt::Display { /// The path with optional captures such as `/users/:id`. const PATH: &'static str; diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 7d91ac6880..8c1ecdab34 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -1,6 +1,6 @@ use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote, quote_spanned}; -use syn::{ItemStruct, LitStr}; +use syn::{parse::Parse, ItemStruct, LitStr, Token}; pub(crate) fn expand(item_struct: ItemStruct) -> syn::Result { let ItemStruct { @@ -18,52 +18,79 @@ pub(crate) fn expand(item_struct: ItemStruct) -> syn::Result { )); } - let Attrs { path } = parse_attrs(attrs)?; + let Attrs { path, rejection } = parse_attrs(attrs)?; match fields { syn::Fields::Named(_) => { let segments = parse_path(&path)?; - Ok(expand_named_fields(ident, path, &segments)) + Ok(expand_named_fields(ident, path, &segments, rejection)) } syn::Fields::Unnamed(fields) => { let segments = parse_path(&path)?; - expand_unnamed_fields(fields, ident, path, &segments) + expand_unnamed_fields(fields, ident, path, &segments, rejection) } - syn::Fields::Unit => expand_unit_fields(ident, path), + syn::Fields::Unit => expand_unit_fields(ident, path, rejection), } } +mod kw { + syn::custom_keyword!(rejection); +} + struct Attrs { path: LitStr, + #[allow(dead_code)] + rejection: Option, +} + +impl Parse for Attrs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let path = input.parse()?; + + let _ = input.parse::(); + + let rejection = if input.parse::().is_ok() { + let content; + syn::parenthesized!(content in input); + Some(content.parse()?) + } else { + None + }; + + Ok(Self { path, rejection }) + } } fn parse_attrs(attrs: &[syn::Attribute]) -> syn::Result { - let mut path = None; + let mut out = None::; for attr in attrs { if attr.path.is_ident("typed_path") { - if path.is_some() { + if out.is_some() { return Err(syn::Error::new_spanned( attr, "`typed_path` specified more than once", )); } else { - path = Some(attr.parse_args()?); + out = Some(attr.parse_args()?); } } } - Ok(Attrs { - path: path.ok_or_else(|| { - syn::Error::new( - Span::call_site(), - "missing `#[typed_path(\"...\")]` attribute", - ) - })?, + out.ok_or_else(|| { + syn::Error::new( + Span::call_site(), + "missing `#[typed_path(\"...\")]` attribute", + ) }) } -fn expand_named_fields(ident: &syn::Ident, path: LitStr, segments: &[Segment]) -> TokenStream { +fn expand_named_fields( + ident: &syn::Ident, + path: LitStr, + segments: &[Segment], + rejection: Option, +) -> TokenStream { let format_str = format_str_from_path(segments); let captures = captures_from_path(segments); @@ -88,6 +115,9 @@ fn expand_named_fields(ident: &syn::Ident, path: LitStr, segments: &[Segment]) - } }; + let rejection_assoc_type = rejection_assoc_type(&rejection); + let map_err_rejection = map_err_rejection(&rejection); + let from_request_impl = quote! { #[::axum::async_trait] #[automatically_derived] @@ -95,10 +125,13 @@ fn expand_named_fields(ident: &syn::Ident, path: LitStr, segments: &[Segment]) - where B: Send, { - type Rejection = <::axum::extract::Path as ::axum::extract::FromRequest>::Rejection; + type Rejection = #rejection_assoc_type; async fn from_request(req: &mut ::axum::extract::RequestParts) -> ::std::result::Result { - ::axum::extract::Path::from_request(req).await.map(|path| path.0) + ::axum::extract::Path::from_request(req) + .await + .map(|path| path.0) + #map_err_rejection } } }; @@ -115,6 +148,7 @@ fn expand_unnamed_fields( ident: &syn::Ident, path: LitStr, segments: &[Segment], + rejection: Option, ) -> syn::Result { let num_captures = segments .iter() @@ -177,6 +211,9 @@ fn expand_unnamed_fields( } }; + let rejection_assoc_type = rejection_assoc_type(&rejection); + let map_err_rejection = map_err_rejection(&rejection); + let from_request_impl = quote! { #[::axum::async_trait] #[automatically_derived] @@ -184,10 +221,13 @@ fn expand_unnamed_fields( where B: Send, { - type Rejection = <::axum::extract::Path as ::axum::extract::FromRequest>::Rejection; + type Rejection = #rejection_assoc_type; async fn from_request(req: &mut ::axum::extract::RequestParts) -> ::std::result::Result { - ::axum::extract::Path::from_request(req).await.map(|path| path.0) + ::axum::extract::Path::from_request(req) + .await + .map(|path| path.0) + #map_err_rejection } } }; @@ -207,7 +247,11 @@ fn simple_pluralize(count: usize, word: &str) -> String { } } -fn expand_unit_fields(ident: &syn::Ident, path: LitStr) -> syn::Result { +fn expand_unit_fields( + ident: &syn::Ident, + path: LitStr, + rejection: Option, +) -> syn::Result { for segment in parse_path(&path)? { match segment { Segment::Capture(_, span) => { @@ -236,6 +280,21 @@ fn expand_unit_fields(ident: &syn::Ident, path: LitStr) -> syn::Result::default()) + } + } else { + quote! { + Err(::axum::http::StatusCode::NOT_FOUND) + } + }; + let from_request_impl = quote! { #[::axum::async_trait] #[automatically_derived] @@ -243,13 +302,13 @@ fn expand_unit_fields(ident: &syn::Ident, path: LitStr) -> syn::Result) -> ::std::result::Result { if req.uri().path() == ::PATH { Ok(Self) } else { - Err(::axum::http::StatusCode::NOT_FOUND) + #create_rejection } } } @@ -314,6 +373,33 @@ enum Segment { Static(String), } +fn path_rejection() -> TokenStream { + quote! { + <::axum::extract::Path as ::axum::extract::FromRequest>::Rejection + } +} + +fn rejection_assoc_type(rejection: &Option) -> TokenStream { + if let Some(rejection) = rejection { + quote! { #rejection } + } else { + path_rejection() + } +} + +fn map_err_rejection(rejection: &Option) -> TokenStream { + if let Some(rejection) = rejection { + let path_rejection = path_rejection(); + quote! { + .map_err(|rejection| { + <#rejection as ::std::convert::From<#path_rejection>>::from(rejection) + }) + } + } else { + quote! {} + } +} + #[test] fn ui() { #[rustversion::stable] diff --git a/axum-macros/tests/typed_path/pass/customize_rejection.rs b/axum-macros/tests/typed_path/pass/customize_rejection.rs new file mode 100644 index 0000000000..41aa7e614e --- /dev/null +++ b/axum-macros/tests/typed_path/pass/customize_rejection.rs @@ -0,0 +1,47 @@ +use axum::{ + extract::rejection::PathRejection, + response::{IntoResponse, Response}, +}; +use axum_extra::routing::{RouterExt, TypedPath}; +use serde::Deserialize; + +#[derive(TypedPath, Deserialize)] +#[typed_path("/:foo", rejection(MyRejection))] +struct MyPathNamed { + foo: String, +} + +#[derive(TypedPath, Deserialize)] +#[typed_path("/", rejection(MyRejection))] +struct MyPathUnit; + +#[derive(TypedPath, Deserialize)] +#[typed_path("/:foo", rejection(MyRejection))] +struct MyPathUnnamed(String); + +struct MyRejection; + +impl IntoResponse for MyRejection { + fn into_response(self) -> Response { + ().into_response() + } +} + +impl From for MyRejection { + fn from(_: PathRejection) -> Self { + Self + } +} + +impl Default for MyRejection { + fn default() -> Self { + Self + } +} + +fn main() { + axum::Router::::new() + .typed_get(|_: Result| async {}) + .typed_post(|_: Result| async {}) + .typed_put(|_: Result| async {}); +} From 68e5d0a65b9eefcf91e71983404e8fd4bfba7949 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Mon, 9 May 2022 00:21:33 +0200 Subject: [PATCH 2/8] changelog --- axum-extra/CHANGELOG.md | 3 +++ axum-macros/CHANGELOG.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index 6614a7f767..4d8512d0e9 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -9,9 +9,12 @@ and this project adheres to [Semantic Versioning]. - **fixed:** `Option` and `Result` are now supported in typed path route handler parameters ([#1001]) - **fixed:** Support wildcards in typed paths ([#1003]) +- **added:** Support using a custom rejection type for `#[derive(TypedPath)]` + instead of `PathRejection` ([#1012]) [#1001]: https://github.com/tokio-rs/axum/pull/1001 [#1003]: https://github.com/tokio-rs/axum/pull/1003 +[#1012]: https://github.com/tokio-rs/axum/pull/1012 # 0.3.0 (27. April, 2022) diff --git a/axum-macros/CHANGELOG.md b/axum-macros/CHANGELOG.md index a983d92b8a..e952982816 100644 --- a/axum-macros/CHANGELOG.md +++ b/axum-macros/CHANGELOG.md @@ -10,10 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **fixed:** `Option` and `Result` are now supported in typed path route handler parameters ([#1001]) - **fixed:** Support wildcards in typed paths ([#1003]) - **added:** Support `#[derive(FromRequest)]` on enums using `#[from_request(via(OtherExtractor))]` ([#1009]) +- **added:** Support using a custom rejection type for `#[derive(TypedPath)]` + instead of `PathRejection` ([#1012]) [#1001]: https://github.com/tokio-rs/axum/pull/1001 [#1003]: https://github.com/tokio-rs/axum/pull/1003 [#1009]: https://github.com/tokio-rs/axum/pull/1009 +[#1012]: https://github.com/tokio-rs/axum/pull/1012 # 0.2.0 (31. March, 2022) From 61a2f1b4b445eee14181d582575362d1af7eca2b Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Mon, 9 May 2022 00:22:42 +0200 Subject: [PATCH 3/8] clean up --- axum-macros/src/typed_path.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 8c1ecdab34..29740ccae5 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -39,7 +39,6 @@ mod kw { struct Attrs { path: LitStr, - #[allow(dead_code)] rejection: Option, } @@ -62,7 +61,7 @@ impl Parse for Attrs { } fn parse_attrs(attrs: &[syn::Attribute]) -> syn::Result { - let mut out = None::; + let mut out = None; for attr in attrs { if attr.path.is_ident("typed_path") { From 0b117c03bcd88b79891c5faacf68dd63cfad16d4 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Tue, 17 May 2022 19:35:43 +0200 Subject: [PATCH 4/8] Apply suggestions from code review Co-authored-by: Jonas Platte --- axum-extra/src/routing/typed.rs | 14 +++----------- axum-macros/src/typed_path.rs | 6 ++---- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/axum-extra/src/routing/typed.rs b/axum-extra/src/routing/typed.rs index e8ea2bf6a7..683d68431c 100644 --- a/axum-extra/src/routing/typed.rs +++ b/axum-extra/src/routing/typed.rs @@ -165,7 +165,7 @@ use http::Uri; /// // Your rejection type must implement `From`. /// // /// // Here you can grab whatever details from the inner rejection -/// // that you need +/// // that you need. /// impl From for UsersMemberRejection { /// fn from(rejection: PathRejection) -> Self { /// # UsersMemberRejection @@ -173,7 +173,7 @@ use http::Uri; /// } /// } /// -/// // And your rejection must implement `IntoResponse`, like all rejections +/// // Your rejection must implement `IntoResponse`, like all rejections. /// impl IntoResponse for UsersMemberRejection { /// fn into_response(self) -> Response { /// # ().into_response() @@ -193,17 +193,9 @@ use http::Uri; /// #[typed_path("/users", rejection(UsersCollectionRejection))] /// struct UsersCollection; /// +/// #[derive(Default)] /// struct UsersCollectionRejection; /// -/// // Since there are no path params the rejection isn't created via `From` but -/// // instead via `Default` -/// impl Default for UsersCollectionRejection { -/// fn default() -> Self { -/// # UsersCollectionRejection -/// // ... -/// } -/// } -/// /// impl IntoResponse for UsersCollectionRejection { /// fn into_response(self) -> Response { /// # ().into_response() diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 29740ccae5..ec59c65375 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -387,16 +387,14 @@ fn rejection_assoc_type(rejection: &Option) -> TokenStream { } fn map_err_rejection(rejection: &Option) -> TokenStream { - if let Some(rejection) = rejection { + rejection.map(|rejection| { let path_rejection = path_rejection(); quote! { .map_err(|rejection| { <#rejection as ::std::convert::From<#path_rejection>>::from(rejection) }) } - } else { - quote! {} - } + }) } #[test] From d289e7f4d065905498e7b24391cdbaab796877ba Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Tue, 17 May 2022 19:46:36 +0200 Subject: [PATCH 5/8] Use `lookahead1` --- axum-macros/src/typed_path.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index ec59c65375..b777e82754 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -48,7 +48,9 @@ impl Parse for Attrs { let _ = input.parse::(); - let rejection = if input.parse::().is_ok() { + let lh = input.lookahead1(); + let rejection = if lh.peek(kw::rejection) { + input.parse::()?; let content; syn::parenthesized!(content in input); Some(content.parse()?) From f44e5a56d8afcd2d04fc52153cf88b663a7cc9f5 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Tue, 17 May 2022 19:47:17 +0200 Subject: [PATCH 6/8] Minor clean ups --- axum-macros/src/typed_path.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index b777e82754..0876b0463c 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -381,22 +381,24 @@ fn path_rejection() -> TokenStream { } fn rejection_assoc_type(rejection: &Option) -> TokenStream { - if let Some(rejection) = rejection { - quote! { #rejection } - } else { - path_rejection() + match rejection { + Some(rejection) => quote! { #rejection }, + None => path_rejection(), } } fn map_err_rejection(rejection: &Option) -> TokenStream { - rejection.map(|rejection| { - let path_rejection = path_rejection(); - quote! { - .map_err(|rejection| { - <#rejection as ::std::convert::From<#path_rejection>>::from(rejection) - }) - } - }) + rejection + .as_ref() + .map(|rejection| { + let path_rejection = path_rejection(); + quote! { + .map_err(|rejection| { + <#rejection as ::std::convert::From<#path_rejection>>::from(rejection) + }) + } + }) + .unwrap_or_default() } #[test] From bc2e7978b651a2ad2b1563476d3eb8448f31d502 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Tue, 17 May 2022 20:26:57 +0200 Subject: [PATCH 7/8] Update axum-macros/src/typed_path.rs Co-authored-by: Jonas Platte --- axum-macros/src/typed_path.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 0876b0463c..4d21479858 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -54,8 +54,10 @@ impl Parse for Attrs { let content; syn::parenthesized!(content in input); Some(content.parse()?) - } else { + } else if lh.is_empty() { None + } else { + return Err(lh.error()); }; Ok(Self { path, rejection }) From a90393d11fb29de19477da394653d63e1e86db44 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Tue, 17 May 2022 20:35:45 +0200 Subject: [PATCH 8/8] Update axum-macros/src/typed_path.rs Co-authored-by: Jonas Platte --- axum-macros/src/typed_path.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 4d21479858..3caeb0bd2c 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -46,18 +46,15 @@ impl Parse for Attrs { fn parse(input: syn::parse::ParseStream) -> syn::Result { let path = input.parse()?; - let _ = input.parse::(); + let rejection = if input.is_empty() { + None + } else { + let _: Token![,] = input.parse()?; + let _: kw::rejection = input.parse()?; - let lh = input.lookahead1(); - let rejection = if lh.peek(kw::rejection) { - input.parse::()?; let content; syn::parenthesized!(content in input); Some(content.parse()?) - } else if lh.is_empty() { - None - } else { - return Err(lh.error()); }; Ok(Self { path, rejection })