fix: honor Accept media parameters

This commit is contained in:
2026-09-30 07:59:50 +09:00
parent 625f4c148d
commit 96db3dd6a8
2 changed files with 77 additions and 25 deletions
+47 -16
View File
@@ -53,6 +53,15 @@ fn require_response_content_type<B>(response: &Response<B>) -> Result<(), Client
} }
} }
fn is_utf8_charset(value: &str) -> bool {
let value = value.trim();
value.eq_ignore_ascii_case("utf-8")
|| value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.is_some_and(|value| value.eq_ignore_ascii_case("utf-8"))
}
/// Returns whether a Content-Type value denotes UTF-8 `application/json`. /// Returns whether a Content-Type value denotes UTF-8 `application/json`.
/// ///
/// The charset parameter is optional because JSON exchanged by this binding is /// The charset parameter is optional because JSON exchanged by this binding is
@@ -72,13 +81,7 @@ pub fn is_json_content_type(value: &str) -> bool {
let Some((name, value)) = parameter.trim().split_once('=') else { let Some((name, value)) = parameter.trim().split_once('=') else {
return false; return false;
}; };
let charset = value.trim(); if !name.trim().eq_ignore_ascii_case("charset") || !is_utf8_charset(value) || saw_charset {
let charset_is_utf8 = charset.eq_ignore_ascii_case("utf-8")
|| charset
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.is_some_and(|value| value.eq_ignore_ascii_case("utf-8"));
if !name.trim().eq_ignore_ascii_case("charset") || !charset_is_utf8 || saw_charset {
return false; return false;
} }
saw_charset = true; saw_charset = true;
@@ -88,7 +91,7 @@ pub fn is_json_content_type(value: &str) -> bool {
fn accepts_json(headers: &HeaderMap) -> bool { fn accepts_json(headers: &HeaderMap) -> bool {
let mut present = false; let mut present = false;
let mut best_match: Option<(u8, f32)> = None; let mut best_match: Option<((u8, u8), f32)> = None;
for value in headers.get_all(ACCEPT) { for value in headers.get_all(ACCEPT) {
present = true; present = true;
let Ok(value) = value.to_str() else { let Ok(value) = value.to_str() else {
@@ -99,17 +102,30 @@ fn accepts_json(headers: &HeaderMap) -> bool {
let media_range = parts.next().unwrap_or_default().trim(); let media_range = parts.next().unwrap_or_default().trim();
let mut quality = 1.0_f32; let mut quality = 1.0_f32;
let mut saw_quality = false; let mut saw_quality = false;
let mut saw_charset = false;
let mut media_parameter_count = 0;
let mut valid = true; let mut valid = true;
for parameter in parts { for parameter in parts {
let Some((name, value)) = parameter.trim().split_once('=') else { let parameter = parameter.trim();
valid = false; let (name, value) = parameter
break; .split_once('=')
}; .map_or((parameter, None), |(name, value)| {
if name.trim().eq_ignore_ascii_case("q") { (name.trim(), Some(value))
if saw_quality { });
if name.is_empty() {
valid = false; valid = false;
break; break;
} }
if saw_quality {
// Parameters after q are Accept extensions and do not
// constrain the selected representation.
continue;
}
if name.eq_ignore_ascii_case("q") {
let Some(value) = value else {
valid = false;
break;
};
saw_quality = true; saw_quality = true;
match value.trim().parse::<f32>() { match value.trim().parse::<f32>() {
Ok(value) if (0.0..=1.0).contains(&value) => quality = value, Ok(value) if (0.0..=1.0).contains(&value) => quality = value,
@@ -118,9 +134,23 @@ fn accepts_json(headers: &HeaderMap) -> bool {
break; break;
} }
} }
} else {
let Some(value) = value else {
valid = false;
break;
};
if !name.eq_ignore_ascii_case("charset")
|| !is_utf8_charset(value)
|| saw_charset
{
valid = false;
break;
}
saw_charset = true;
media_parameter_count += 1;
} }
} }
let specificity = if media_range.eq_ignore_ascii_case("application/json") { let media_specificity = if media_range.eq_ignore_ascii_case("application/json") {
Some(2) Some(2)
} else if media_range.eq_ignore_ascii_case("application/*") { } else if media_range.eq_ignore_ascii_case("application/*") {
Some(1) Some(1)
@@ -129,7 +159,8 @@ fn accepts_json(headers: &HeaderMap) -> bool {
} else { } else {
None None
}; };
if valid && let Some(specificity) = specificity { if valid && let Some(media_specificity) = media_specificity {
let specificity = (media_specificity, media_parameter_count);
match best_match { match best_match {
Some((best_specificity, _)) if best_specificity > specificity => {} Some((best_specificity, _)) if best_specificity > specificity => {}
Some((best_specificity, best_quality)) if best_specificity == specificity => { Some((best_specificity, best_quality)) if best_specificity == specificity => {
+23 -2
View File
@@ -73,16 +73,37 @@ fn inbound_http_metadata_enforces_post_route_and_json() {
.unwrap(); .unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_route).is_err()); assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_route).is_err());
for accept in [
"text/plain, */*;q=1, application/json;q=0",
"application/json; charset=utf-16",
"application/json; profile=future",
] {
let incompatible_accept = Request::builder() let incompatible_accept = Request::builder()
.method(Method::POST) .method(Method::POST)
.uri("/wip/v1/fetch") .uri("/wip/v1/fetch")
.header("content-type", "application/json") .header("content-type", "application/json")
.header("accept", "text/plain, */*;q=1, application/json;q=0") .header("accept", accept)
.body(Vec::<u8>::new()) .body(Vec::<u8>::new())
.unwrap(); .unwrap();
let error = validate_http_request(&endpoint, Route::Fetch, &incompatible_accept).unwrap_err(); let error =
validate_http_request(&endpoint, Route::Fetch, &incompatible_accept).unwrap_err();
assert!(matches!(&error, RequestMetadataError::NotAcceptable)); assert!(matches!(&error, RequestMetadataError::NotAcceptable));
assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE); assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
}
for accept in [
"application/json; charset=UTF-8",
"application/json; q=1; profile=accept-extension",
] {
let compatible_accept = Request::builder()
.method(Method::POST)
.uri("/wip/v1/fetch")
.header("content-type", "application/json")
.header("accept", accept)
.body(Vec::<u8>::new())
.unwrap();
assert!(validate_http_request(&endpoint, Route::Fetch, &compatible_accept).is_ok());
}
let unsupported_media_type = Request::builder() let unsupported_media_type = Request::builder()
.method(Method::POST) .method(Method::POST)