@@ -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