Skip to content

Commit e22ea13

Browse files
committed
fix(rpc): parse Accept header as a list of media ranges (#341)
ApiVersion::from_accept_header() matched the entire trimmed header value as a single media type, so any Accept header carrying more than one media range (comma-separated) or any parameters (e.g. a q value) failed to match and fell through to a 406, even when a supported version was present in the header. Per RFC 9110 SS12.5.1, Accept is a comma-separated list of media ranges, each optionally followed by ;-separated parameters including q. Rewrite the parser to split on comma, strip parameters per range, and pick the supported version with the highest q among ranges that are not explicitly marked unacceptable (q=0). Media types and the q parameter name are matched case-insensitively (RFC 9110 SS8.3.1/SS5.6.6/SS12.4.2). A q value that fails to parse, or parses outside the valid [0,1] range -- including nan/inf/-inf, all accepted by f32::from_str's grammar, and any finite out-of-range value such as -1 or 1.5 -- is treated uniformly as q=1, i.e. maximally preferred rather than merely acceptable. RFC 9110 SS12.4.1 permits disregarding an unsatisfiable Accept header entirely, so this is within spec; documented as a known interaction with the tie-break rule to revisit once a second ApiVersion exists (a malformed q could then out-rank a client's genuinely preferred version). Also adds status_for_accept()-based middleware tests exercising the same header values through the actual extract_version layer end to end, and a best_match_with_quality() test-only helper so the quality-based tie-breaking rule is observable (and falsifiable) even though only one ApiVersion variant exists today. Fixes #341
1 parent 97f8da0 commit e22ea13

2 files changed

Lines changed: 349 additions & 11 deletions

File tree

‎crates/malachite-app/src/rpc/middleware.rs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,60 @@ mod tests {
114114
None
115115
);
116116
}
117+
118+
/// Builds a minimal router with only the `extract_version` middleware
119+
/// attached, and returns the response status for a given `Accept` header
120+
/// value. Isolated from the rest of the app's routes/state so these tests
121+
/// exercise exactly the middleware's negotiation behavior end to end,
122+
/// the same way a real request would reach it.
123+
async fn status_for_accept(accept: &str) -> StatusCode {
124+
use axum::body::Body;
125+
use axum::routing::get;
126+
use axum::Router;
127+
use tower::ServiceExt;
128+
129+
let app = Router::new()
130+
.route("/test", get(|| async { StatusCode::OK }))
131+
.layer(axum::middleware::from_fn(extract_version));
132+
133+
let req = Request::builder()
134+
.uri("/test")
135+
.header(header::ACCEPT, accept)
136+
.body(Body::empty())
137+
.unwrap();
138+
139+
app.oneshot(req).await.unwrap().status()
140+
}
141+
142+
#[tokio::test]
143+
async fn test_accept_header_with_parameters() {
144+
assert_eq!(
145+
status_for_accept("application/vnd.arc.v1+json; q=0.9").await,
146+
StatusCode::OK
147+
);
148+
}
149+
150+
#[tokio::test]
151+
async fn test_accept_header_with_multiple_ranges() {
152+
assert_eq!(
153+
status_for_accept("text/html, application/vnd.arc.v1+json").await,
154+
StatusCode::OK
155+
);
156+
}
157+
158+
#[tokio::test]
159+
async fn test_accept_header_zero_quality_supported_version_is_not_acceptable() {
160+
assert_eq!(
161+
status_for_accept("application/vnd.arc.v1+json; q=0").await,
162+
StatusCode::NOT_ACCEPTABLE
163+
);
164+
}
165+
166+
#[tokio::test]
167+
async fn test_accept_header_only_unsupported_versions_is_not_acceptable() {
168+
assert_eq!(
169+
status_for_accept("application/vnd.arc.v2+json, application/vnd.arc.v99+json").await,
170+
StatusCode::NOT_ACCEPTABLE
171+
);
172+
}
117173
}

‎crates/malachite-app/src/rpc/version.rs‎

Lines changed: 293 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,25 +56,116 @@ impl ApiVersion {
5656
/// - `application/json` -> default (V1)
5757
/// - Missing/empty -> default (V1)
5858
///
59-
/// Returns `None` if the header specifies an unsupported version or the
60-
/// format is unrecognized/malformed (e.g. "text/html")
59+
/// Per RFC 9110 §12.5.1, the `Accept` field value is a comma-separated list
60+
/// of media ranges, each optionally carrying parameters including a `q`
61+
/// (quality) value. This parses each range individually rather than
62+
/// matching the whole header as one media type, so headers such as
63+
/// `application/vnd.arc.v1+json; q=0.9` or
64+
/// `text/html, application/vnd.arc.v1+json` correctly negotiate V1.
65+
///
66+
/// Among all acceptable ranges (`q` != 0) that map to a supported version,
67+
/// the one with the highest `q` value is selected (ties keep the
68+
/// first-encountered range, matching the header's order -- per RFC 9110
69+
/// §12.5.1 this should really be the *most specific* range on a tie, but
70+
/// with only one version defined today there is nothing for specificity
71+
/// to distinguish; this will need revisiting once a second version
72+
/// exists). A `q` value that fails to parse, or parses outside the valid
73+
/// `[0, 1]` range -- including `nan`/`inf`/`-inf` (all accepted by
74+
/// `f32::from_str`'s grammar) and any finite out-of-range value such as
75+
/// `-1` or `1.5` -- is treated as `q=1`, i.e. *maximally* preferred, not
76+
/// merely acceptable (RFC 9110 §12.4.1 permits disregarding an
77+
/// unsatisfiable Accept header entirely, which this is squarely inside
78+
/// of). With only one version defined today a malformed range can only
79+
/// ever tie with a conforming one, so this is invisible; once a second
80+
/// version exists, a malformed q on an old-version range would
81+
/// out-rank a client's genuinely preferred new-version range -- revisit
82+
/// this alongside the tie-break-by-specificity gap above when that
83+
/// happens. Media types and parameter names are matched
84+
/// case-insensitively per RFC 9110 §8.3.1/§5.6.6/§12.4.2.
85+
///
86+
/// Returns `None` if no range in the header specifies a supported version
87+
/// (e.g. "text/html" alone, or every versioned range has `q=0`).
88+
///
89+
/// Known deviations from full RFC 9110 conformance, none of which affect
90+
/// any header this API actually needs to accept: quoted parameter values
91+
/// containing a comma are not parsed (`;`-params are split naively on
92+
/// `,`), and `type/*` partial-wildcard ranges are not recognized (only
93+
/// the literal `*/*`).
6194
pub fn from_accept_header(value: &str) -> Option<Self> {
95+
Self::best_match_with_quality(value).map(|(version, _q)| version)
96+
}
97+
98+
/// Same as [`Self::from_accept_header`], but also returns the `q` value
99+
/// of the winning range. Exists mainly so tests can observe the
100+
/// tie-breaking rule (highest `q` wins) directly, since with only one
101+
/// [`ApiVersion`] variant defined today, the returned version alone
102+
/// can't distinguish "highest q" from "first" or "last" range winning.
103+
fn best_match_with_quality(value: &str) -> Option<(Self, f32)> {
62104
let trimmed = value.trim();
63105

64-
// Empty or generic JSON defaults to V1
65-
if trimmed.is_empty() || trimmed == MEDIA_TYPE_JSON || trimmed == MEDIA_TYPE_ANY {
66-
return Some(Self::default());
106+
// Empty defaults to V1
107+
if trimmed.is_empty() {
108+
return Some((Self::default(), 1.0));
67109
}
68110

69-
// Parse versioned media type
70-
if let Some(version_part) = trimmed.strip_prefix(MEDIA_TYPE_PREFIX) {
71-
if let Some(version_str) = version_part.strip_suffix("+json") {
72-
return ApiVersion::from_str(version_str).ok();
111+
let mut best: Option<(Self, f32)> = None;
112+
let mut best_q = -1.0f32;
113+
114+
for media_range in trimmed.split(',') {
115+
let mut parts = media_range.split(';');
116+
let media_type = parts.next().unwrap_or("").trim();
117+
if media_type.is_empty() {
118+
continue;
119+
}
120+
121+
let mut q = 1.0f32;
122+
for param in parts {
123+
let param = param.trim();
124+
if param.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("q=")) {
125+
// Per RFC 9110 SS12.4.2, a qvalue is at most 1, with no
126+
// negative values, so anything outside [0, 1] -- finite
127+
// or not -- cannot come from a conforming sender.
128+
// `nan`/`inf`/`-inf` all parse successfully
129+
// (f32::from_str's grammar accepts them) rather than
130+
// failing to parse, so they must be filtered out
131+
// explicitly alongside any finite out-of-range value
132+
// (e.g. -1 or 1.5): all of it is equally malformed,
133+
// and is treated uniformly as fully acceptable (q=1)
134+
// rather than rejecting the range outright.
135+
q = param[2..]
136+
.trim()
137+
.parse::<f32>()
138+
.ok()
139+
.filter(|v| (0.0..=1.0).contains(v))
140+
.unwrap_or(1.0);
141+
}
142+
}
143+
144+
// q=0 explicitly marks this range as not acceptable.
145+
if q <= 0.0 {
146+
continue;
147+
}
148+
149+
let media_type = media_type.to_ascii_lowercase();
150+
let version = if media_type == MEDIA_TYPE_JSON || media_type == MEDIA_TYPE_ANY {
151+
Some(Self::default())
152+
} else if let Some(version_part) = media_type.strip_prefix(MEDIA_TYPE_PREFIX) {
153+
version_part
154+
.strip_suffix("+json")
155+
.and_then(|v| ApiVersion::from_str(v).ok())
156+
} else {
157+
None
158+
};
159+
160+
if let Some(version) = version {
161+
if q > best_q {
162+
best = Some((version, q));
163+
best_q = q;
164+
}
73165
}
74166
}
75167

76-
// Unrecognized/malformed format defaults to None
77-
None
168+
best
78169
}
79170
}
80171

@@ -177,6 +268,197 @@ mod tests {
177268
assert_eq!(ApiVersion::from_accept_header("something/random"), None);
178269
}
179270

271+
#[test]
272+
fn test_from_accept_header_with_quality_parameter() {
273+
assert_eq!(
274+
ApiVersion::from_accept_header("application/vnd.arc.v1+json; q=0.9"),
275+
Some(ApiVersion::V1)
276+
);
277+
}
278+
279+
#[test]
280+
fn test_from_accept_header_multiple_media_ranges() {
281+
assert_eq!(
282+
ApiVersion::from_accept_header("text/html, application/vnd.arc.v1+json"),
283+
Some(ApiVersion::V1)
284+
);
285+
}
286+
287+
#[test]
288+
fn test_from_accept_header_generic_json_in_range_list() {
289+
assert_eq!(
290+
ApiVersion::from_accept_header("text/html, application/json"),
291+
Some(ApiVersion::V1)
292+
);
293+
}
294+
295+
#[test]
296+
fn test_from_accept_header_wildcard_in_range_list() {
297+
assert_eq!(
298+
ApiVersion::from_accept_header("text/plain; q=0.5, */*; q=0.1"),
299+
Some(ApiVersion::V1)
300+
);
301+
}
302+
303+
#[test]
304+
fn test_from_accept_header_zero_quality_is_not_acceptable() {
305+
// A supported version with q=0 is explicitly marked unacceptable
306+
assert_eq!(
307+
ApiVersion::from_accept_header("application/vnd.arc.v1+json; q=0"),
308+
None
309+
);
310+
// ...even when a generic-JSON range with q=0 is also present
311+
assert_eq!(
312+
ApiVersion::from_accept_header("application/json; q=0, application/vnd.arc.v1+json; q=0"),
313+
None
314+
);
315+
}
316+
317+
#[test]
318+
fn test_from_accept_header_only_unsupported_versions_in_list() {
319+
assert_eq!(
320+
ApiVersion::from_accept_header(
321+
"application/vnd.arc.v2+json, application/vnd.arc.v99+json"
322+
),
323+
None
324+
);
325+
}
326+
327+
#[test]
328+
fn test_from_accept_header_supported_range_after_unsupported() {
329+
assert_eq!(
330+
ApiVersion::from_accept_header(
331+
"application/vnd.arc.v99+json, application/vnd.arc.v1+json"
332+
),
333+
Some(ApiVersion::V1)
334+
);
335+
}
336+
337+
#[test]
338+
fn test_from_accept_header_whitespace_around_commas_and_parameters() {
339+
assert_eq!(
340+
ApiVersion::from_accept_header(
341+
" text/html , application/vnd.arc.v1+json ; q=0.8 "
342+
),
343+
Some(ApiVersion::V1)
344+
);
345+
}
346+
347+
#[test]
348+
fn test_from_accept_header_malformed_quality_value_is_treated_as_acceptable() {
349+
// An unparsable q value falls back to fully acceptable (q=1) rather
350+
// than rejecting the range outright.
351+
assert_eq!(
352+
ApiVersion::from_accept_header("application/vnd.arc.v1+json; q=not-a-number"),
353+
Some(ApiVersion::V1)
354+
);
355+
}
356+
357+
#[test]
358+
fn test_from_accept_header_nan_quality_is_treated_as_acceptable() {
359+
// f32::from_str's grammar accepts "nan" (case-insensitively), so it
360+
// does not fall into the parse-failure path -- it must be filtered
361+
// out explicitly (it fails the [0,1] range check), or a NaN q
362+
// compares false against everything (q<=0.0 is false, so not
363+
// skipped; q>best_q is also false, so never selected), making the
364+
// range silently unselectable rather than "fully acceptable" as
365+
// documented.
366+
assert_eq!(
367+
ApiVersion::from_accept_header("application/vnd.arc.v1+json; q=nan"),
368+
Some(ApiVersion::V1)
369+
);
370+
assert_eq!(
371+
ApiVersion::from_accept_header("application/vnd.arc.v1+json; q=NaN"),
372+
Some(ApiVersion::V1)
373+
);
374+
}
375+
376+
#[test]
377+
fn test_from_accept_header_infinite_quality_falls_back_to_acceptable() {
378+
// f32::from_str also accepts "inf"/"-inf"/"infinity", none of which
379+
// are producible by a conforming sender (RFC 9110 SS12.4.2 caps a
380+
// qvalue at 1, with no negative values). Like `nan` and an
381+
// unparsable string, both fall outside the valid [0,1] range and
382+
// fall back to the documented q=1 default (see the dedicated
383+
// out-of-range test below for the finite case, e.g. q=1.5).
384+
assert_eq!(
385+
ApiVersion::best_match_with_quality("application/vnd.arc.v1+json; q=inf"),
386+
Some((ApiVersion::V1, 1.0))
387+
);
388+
assert_eq!(
389+
ApiVersion::best_match_with_quality("application/vnd.arc.v1+json; q=-infinity"),
390+
Some((ApiVersion::V1, 1.0))
391+
);
392+
}
393+
394+
#[test]
395+
fn test_from_accept_header_out_of_range_finite_quality_falls_back_to_acceptable() {
396+
assert_eq!(
397+
ApiVersion::best_match_with_quality("application/vnd.arc.v1+json; q=-1"),
398+
Some((ApiVersion::V1, 1.0))
399+
);
400+
assert_eq!(
401+
ApiVersion::best_match_with_quality("application/vnd.arc.v1+json; q=-0.5"),
402+
Some((ApiVersion::V1, 1.0))
403+
);
404+
assert_eq!(
405+
ApiVersion::best_match_with_quality("application/vnd.arc.v1+json; q=1.5"),
406+
Some((ApiVersion::V1, 1.0))
407+
);
408+
}
409+
410+
411+
#[test]
412+
fn test_from_accept_header_media_type_case_insensitive() {
413+
// RFC 9110 SS8.3.1: "The type and subtype tokens are case-insensitive."
414+
assert_eq!(
415+
ApiVersion::from_accept_header("Application/JSON"),
416+
Some(ApiVersion::V1)
417+
);
418+
assert_eq!(
419+
ApiVersion::from_accept_header("APPLICATION/VND.ARC.V1+JSON"),
420+
Some(ApiVersion::V1)
421+
);
422+
}
423+
424+
#[test]
425+
fn test_from_accept_header_quality_parameter_name_case_insensitive() {
426+
// RFC 9110 SS12.4.2: "a common parameter, named q (case-insensitive)".
427+
// A capitalized `Q=0` must still mark the range unacceptable -- prior
428+
// to case-insensitive matching this silently fell back to the q=1
429+
// default and served a representation the client explicitly
430+
// rejected.
431+
assert_eq!(
432+
ApiVersion::from_accept_header("application/vnd.arc.v1+json; Q=0"),
433+
None
434+
);
435+
}
436+
437+
#[test]
438+
fn test_from_accept_header_prefers_highest_quality_supported_range() {
439+
// Only V1 exists today, so the *version* returned by
440+
// `from_accept_header` can't distinguish "highest q wins" from
441+
// "first wins" or "last wins" -- both ranges map to V1 either way.
442+
// Assert on the winning q via `best_match_with_quality` instead, so
443+
// this test actually fails if the selection rule regresses (e.g. to
444+
// first- or last-wins) rather than only failing once a second
445+
// version exists to tell the outcomes apart.
446+
assert_eq!(
447+
ApiVersion::best_match_with_quality(
448+
"application/vnd.arc.v1+json; q=0.3, application/json; q=0.9"
449+
),
450+
Some((ApiVersion::V1, 0.9))
451+
);
452+
// Same ranges, reversed order: still picks q=0.9, confirming this
453+
// is quality-based selection and not header order.
454+
assert_eq!(
455+
ApiVersion::best_match_with_quality(
456+
"application/json; q=0.9, application/vnd.arc.v1+json; q=0.3"
457+
),
458+
Some((ApiVersion::V1, 0.9))
459+
);
460+
}
461+
180462
#[test]
181463
fn test_from_str() {
182464
assert_eq!("v1".parse::<ApiVersion>().unwrap(), ApiVersion::V1);

0 commit comments

Comments
 (0)