Skip to content

Commit 0fdd9bd

Browse files
committed
feat: Support */* and $name/* in accept headers
1 parent b0417a7 commit 0fdd9bd

File tree

3 files changed

+98
-7
lines changed

3 files changed

+98
-7
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.0.5] - 2025-07-08
11+
12+
### Changed
13+
14+
- This adds proper support for stars in accept headers
15+
- Forbid the use of * in definitions
16+
1017
## [0.0.4] - 2025-07-08
1118

1219
### Added

axum-accept-macros/src/lib.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
#![deny(missing_docs)]
55
extern crate proc_macro;
66

7+
use std::collections::HashMap;
8+
79
use mediatype::MediaTypeBuf;
810
use proc_macro::TokenStream;
911
use quote::quote;
@@ -66,7 +68,12 @@ pub fn derive_accept_extractor(input: TokenStream) -> TokenStream {
6668
})
6769
});
6870

71+
// Match arms with ty, subty and suffix
6972
let mut match_arms = Vec::new();
73+
// Match arms with ty only (for checking mediatypes like text/*)
74+
let mut match_arms_tys = HashMap::new();
75+
// Store first variant to fall back to if we don't have a default.
76+
let mut first_variant_name = None;
7077

7178
for variant in &data.variants {
7279
let variant_name = &variant.ident;
@@ -79,6 +86,16 @@ pub fn derive_accept_extractor(input: TokenStream) -> TokenStream {
7986
mediatype.suffix().map(|s| s.as_str()),
8087
);
8188

89+
if ty == "*" || subty == "*" {
90+
panic!("Please use a concrete mediatype");
91+
}
92+
93+
if first_variant_name.is_none() {
94+
first_variant_name = Some(variant_name.clone());
95+
}
96+
97+
match_arms_tys.insert(ty.to_string(), variant_name);
98+
8299
match &variant.fields {
83100
Fields::Unit => {
84101
// quote encodes None to empty string, so we need to take extra
@@ -107,6 +124,22 @@ pub fn derive_accept_extractor(input: TokenStream) -> TokenStream {
107124
None
108125
};
109126

127+
let handle_star_star = if has_default {
128+
quote! {
129+
return Ok(#name::default());
130+
}
131+
} else {
132+
quote! {
133+
return Ok(#name::#first_variant_name);
134+
}
135+
};
136+
137+
let match_arms_tys = match_arms_tys.iter().map(|(ty, variant_name)| {
138+
quote! {
139+
(#ty) => return Ok(#name::#variant_name),
140+
}
141+
});
142+
110143
let expanded = quote! {
111144
impl #impl_generics axum::extract::FromRequestParts<S> for #name #ty_generics #where_clause {
112145
type Rejection = axum_accept::AcceptRejection;
@@ -115,9 +148,23 @@ pub fn derive_accept_extractor(input: TokenStream) -> TokenStream {
115148
let mediatypes = axum_accept::parse_mediatypes(&parts.headers)?;
116149
#check_and_return_default
117150
for mt in mediatypes {
118-
match (mt.ty.as_str(), mt.subty.as_str(), mt.suffix.map(|s| s.as_str())) {
119-
#(#match_arms)*
120-
_ => {} // continue searching
151+
match (mt.ty.as_str(), mt.subty.as_str()) {
152+
("*", "*") => {
153+
// return either the the default or the first
154+
// variant
155+
#handle_star_star
156+
},
157+
// do we have any mediatype that shares the main type?
158+
// e.g. we offer text/plain and get accept: text/*
159+
(_, "*") => match (mt.ty.as_str()) {
160+
#(#match_arms_tys)*
161+
_ => {} // continue searching
162+
},
163+
// do proper matching
164+
_ => match (mt.ty.as_str(), mt.subty.as_str(), mt.suffix.map(|s| s.as_str())) {
165+
#(#match_arms)*
166+
_ => {} // continue searching
167+
},
121168
}
122169
}
123170

axum-accept/src/lib.rs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ mod tests {
3535

3636
#[derive(Debug, AcceptExtractor)]
3737
enum Accept {
38-
#[accept(mediatype = "text/*")]
39-
Text,
4038
#[accept(mediatype = "text/plain")]
4139
TextPlain,
4240
#[accept(mediatype = "application/json")]
@@ -118,13 +116,39 @@ mod tests {
118116
Ok(())
119117
}
120118

119+
#[tokio::test]
120+
async fn test_accept_extractor_star() -> Result<(), Box<dyn std::error::Error>> {
121+
let req = Request::builder()
122+
.header("accept", "text/csv,text/*")
123+
.body(Body::from(""))?;
124+
let state = ();
125+
let media_type = Accept::from_request(req, &state).await;
126+
let Ok(Accept::TextPlain) = media_type else {
127+
panic!("expected text/*, got {:?}", media_type)
128+
};
129+
Ok(())
130+
}
131+
132+
#[tokio::test]
133+
async fn test_accept_extractor_star_star() -> Result<(), Box<dyn std::error::Error>> {
134+
let req = Request::builder()
135+
.header("accept", "text/csv,*/*")
136+
.body(Body::from(""))?;
137+
let state = ();
138+
let media_type = Accept::from_request(req, &state).await;
139+
let Ok(Accept::TextPlain) = media_type else {
140+
panic!("expected text/plain")
141+
};
142+
Ok(())
143+
}
144+
121145
#[derive(Debug, AcceptExtractor, Default)]
122146
enum AcceptWithDefault {
147+
#[accept(mediatype = "application/json")]
148+
ApplicationJson,
123149
#[default]
124150
#[accept(mediatype = "text/plain")]
125151
TextPlain,
126-
#[accept(mediatype = "application/json")]
127-
ApplicationJson,
128152
}
129153

130154
#[tokio::test]
@@ -146,4 +170,17 @@ mod tests {
146170
};
147171
Ok(())
148172
}
173+
174+
#[tokio::test]
175+
async fn test_accept_extractor_star_star_default() -> Result<(), Box<dyn std::error::Error>> {
176+
let req = Request::builder()
177+
.header("accept", "text/csv,*/*")
178+
.body(Body::from(""))?;
179+
let state = ();
180+
let media_type = AcceptWithDefault::from_request(req, &state).await;
181+
let Ok(AcceptWithDefault::TextPlain) = media_type else {
182+
panic!("expected text/plain (default)")
183+
};
184+
Ok(())
185+
}
149186
}

0 commit comments

Comments
 (0)