Skip to main content

rama_http_headers/
client_hints.rs

1use std::time::Duration;
2
3use rama_http_types::{HeaderName, HeaderValue};
4use rama_utils::collections::NonEmptySmallVec;
5
6use crate::util::{self, IterExt};
7use crate::{Error, HeaderDecode, HeaderEncode, TypedHeader};
8
9macro_rules! client_hint {
10    (
11        #[doc = $ch_doc:literal]
12        pub enum ClientHint {
13            $(
14                #[doc = $doc:literal]
15                $name:ident($($str:literal),*),
16            )+
17        }
18    ) => {
19        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20        pub enum ClientHint {
21            $(
22                #[doc = $doc]
23                $name,
24            )+
25        }
26
27        impl ClientHint {
28            #[doc = "Checks if the client hint is low entropy, meaning that it will be send by default."]
29            #[must_use] pub fn is_low_entropy(&self) -> bool {
30                matches!(self, Self::SaveData | Self::Ua | Self::Mobile | Self::Platform)
31            }
32
33            #[inline]
34            #[doc = "Attempts to convert a `HeaderName` to a `ClientHint`."]
35            pub fn match_header_name(name: &::rama_http_types::HeaderName) -> Option<Self> {
36                name.try_into().ok()
37            }
38
39            #[doc = "All header-name spellings this client hint answers to, as borrowed `'static` strs: the preferred (`Sec-CH-` prefixed) form first, followed by any legacy bare alias (e.g. `[\"sec-ch-ect\", \"ect\"]`). Allocation-free — the slice is statically promoted."]
40            #[must_use] pub fn header_name_strs(&self) -> &'static [&'static str] {
41                match self {
42                    $(
43                        Self::$name => {
44                            const NAMES: &[&str] = &[$($str,)+];
45                            NAMES
46                        },
47                    )+
48                }
49            }
50
51            #[doc = "Return an iterator of all header names for this client hint. Allocation-free."]
52            pub fn iter_header_names(&self) -> impl Iterator<Item = ::rama_http_types::HeaderName> {
53                self.header_name_strs()
54                    .iter()
55                    .copied()
56                    .map(::rama_http_types::HeaderName::from_static)
57            }
58
59            #[doc = "Returns the preferred string representation of the client hint."]
60            #[must_use] pub fn as_str(&self) -> &'static str {
61                self.header_name_strs()[0]
62            }
63        }
64
65        rama_utils::macros::error::static_str_error! {
66            /// Client Hint Parsing Error
67            pub struct ClientHintParsingError;
68        }
69
70        impl TryFrom<&str> for ClientHint {
71            type Error = ClientHintParsingError;
72
73            fn try_from(name: &str) -> Result<Self, Self::Error> {
74                rama_utils::macros::match_ignore_ascii_case_str! {
75                    match (name) {
76                        $(
77                            $($str)|+ => Ok(Self::$name),
78                        )+
79                        _ => Err(ClientHintParsingError),
80                    }
81                }
82            }
83        }
84
85        impl TryFrom<String> for ClientHint {
86            type Error = ClientHintParsingError;
87
88            fn try_from(name: String) -> Result<Self, Self::Error> {
89                Self::try_from(name.as_str())
90            }
91        }
92
93        impl TryFrom<::rama_http_types::HeaderName> for ClientHint {
94            type Error = ClientHintParsingError;
95
96            fn try_from(name: ::rama_http_types::HeaderName) -> Result<Self, Self::Error> {
97                Self::try_from(name.as_str())
98            }
99        }
100
101        impl TryFrom<&::rama_http_types::HeaderName> for ClientHint {
102            type Error = ClientHintParsingError;
103
104            fn try_from(name: &::rama_http_types::HeaderName) -> Result<Self, Self::Error> {
105                Self::try_from(name.as_str())
106            }
107        }
108
109        impl std::str::FromStr for ClientHint {
110            type Err = ClientHintParsingError;
111
112            #[inline]
113            fn from_str(s: &str) -> Result<Self, Self::Err> {
114                Self::try_from(s)
115            }
116        }
117
118        impl std::fmt::Display for ClientHint {
119            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120                write!(f, "{}", self.as_str())
121            }
122        }
123
124        impl serde::Serialize for ClientHint {
125            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
126            where
127                S: serde::Serializer,
128            {
129                serializer.serialize_str(self.as_str())
130            }
131        }
132
133        impl<'de> serde::Deserialize<'de> for ClientHint {
134            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
135            where
136                D: serde::Deserializer<'de>,
137            {
138                use serde::de::Error;
139                let s = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
140                Self::try_from(s.as_ref()).map_err(D::Error::custom)
141            }
142        }
143
144        #[doc = "Returns an iterator over all client hints."]
145        pub fn all_client_hints() -> impl Iterator<Item = ClientHint> {
146            [
147                $(
148                    ClientHint::$name,
149                )+
150            ].into_iter()
151        }
152
153        #[doc = "Returns an iterator over all client hint header name strings."]
154        pub fn all_client_hint_header_name_strings() -> impl Iterator<Item = &'static str> {
155            [
156                $(
157                    $($str,)+
158                )+
159            ].into_iter()
160        }
161
162        #[doc = "Returns an iterator over all client hint header names."]
163        pub fn all_client_hint_header_names() -> impl Iterator<Item = ::rama_http_types::HeaderName> {
164            all_client_hint_header_name_strings().map(::rama_http_types::HeaderName::from_static)
165        }
166    };
167}
168
169// NOTE: we are open to contributions to this module,
170// e.g. in case you wish typed headers for each or some of these client hint headers,
171// we gladly mentor and guide you in the process.
172
173client_hint! {
174    #[doc = "Client Hints are a set of HTTP Headers and a JavaScript API that allow web browsers to send detailed information about the client device and browser to web servers. They are designed to be a successor to User-Agent, and provide a standardized way for web servers to optimize content for the client without relying on unreliable user-agent string-based detection or browser fingerprinting techniques."]
175    pub enum ClientHint {
176        /// Sec-CH-UA represents a user agent's branding and version.
177        Ua("sec-ch-ua"),
178        /// Sec-CH-UA-Full-Version represents the user agent's full version.
179        FullVersion("sec-ch-ua-full-version"),
180        /// Sec-CH-UA-Full-Version-List represents the full version for each brand in its brands list.
181        FullVersionList("sec-ch-ua-full-version-list"),
182        /// Sec-CH-UA-Platform represents the platform on which a given user agent is executing.
183        Platform("sec-ch-ua-platform"),
184        /// Sec-CH-UA-Platform-Version represents the platform version on which a given user agent is executing.
185        PlatformVersion("sec-ch-ua-platform-version"),
186        /// Sec-CH-UA-Arch represents the architecture of the platform on which a given user agent is executing.
187        Arch("sec-ch-ua-arch"),
188        /// Sec-CH-UA-Bitness represents the bitness of the architecture of the platform on which a given user agent is executing.
189        Bitness("sec-ch-ua-bitness"),
190        /// Sec-CH-UA-WoW64 is used to detect whether or not a user agent binary is running in 32-bit mode on 64-bit Windows.
191        Wow64("sec-ch-ua-wow64"),
192        /// Sec-CH-UA-Model represents the device on which a given user agent is executing.
193        Model("sec-ch-ua-model"),
194        /// Sec-CH-UA-Mobile is used to detect whether or not a user agent prefers a «mobile» user experience.
195        Mobile("sec-ch-ua-mobile"),
196        /// Sec-CH-UA-Form-Factors represents the form-factors of a device, historically represented as a `<deviceCompat>` token in the User-Agent string.
197        FormFactor("sec-ch-ua-form-factors"),
198        /// Sec-CH-Lang  (or Lang) represents the user's language preference.
199        Lang("sec-ch-lang", "lang"),
200        /// Sec-CH-Save-Data (or Save-Data) represents the user agent's preference for reduced data usage.
201        SaveData("sec-ch-save-data", "save-data"),
202        /// Sec-CH-Width gives a server the layout width of the image.
203        Width("sec-ch-width"),
204        /// Sec-CH-Viewport-Width (or Viewport-Width) is the width of the user's viewport in CSS pixels.
205        ViewportWidth("sec-ch-viewport-width", "viewport-width"),
206        /// Sec-CH-Viewport-Height represents the user-agent's current viewport height.
207        ViewportHeight("sec-ch-viewport-height"),
208        /// Sec-CH-DPR (or DPR) reports the ratio of physical pixels to CSS pixels of the user's screen.
209        Dpr("sec-ch-dpr", "dpr"),
210        /// Sec-CH-Device-Memory (or Device-Memory) reveals the approximate amount of memory the current device has in GiB. Because this information could be used to fingerprint users, the value of Device-Memory is intentionally coarse. Valid values are 0.25, 0.5, 1, 2, 4, and 8.
211        DeviceMemory("sec-ch-device-memory", "device-memory"),
212        /// Sec-CH-RTT (or RTT) provides the approximate Round Trip Time, in milliseconds, on the application layer. The RTT hint, unlike transport layer RTT, includes server processing time. The value of RTT is rounded to the nearest 25 milliseconds to prevent fingerprinting.
213        Rtt("sec-ch-rtt", "rtt"),
214        /// Sec-CH-Downlink (or Downlink) expressed in megabits per second (Mbps), reveals the approximate downstream speed of the user's connection. The value is rounded to the nearest multiple of 25 kilobits per second. Because again, fingerprinting.
215        Downlink("sec-ch-downlink", "downlink"),
216        /// Sec-CH-ECT (or ECT) stands for Effective Connection Type. Its value is one of an enumerated list of connection types, each of which describes a connection within specified ranges of both RTT and Downlink values. Valid values for ECT are 4g, 3g, 2g, and slow-2g.
217        Ect("sec-ch-ect", "ect"),
218        /// Sec-CH-Prefers-Color-Scheme represents the user's preferred color scheme.
219        PrefersColorScheme("sec-ch-prefers-color-scheme"),
220        /// Sec-CH-Prefers-Reduced-Motion is used to detect if the user has requested the system minimize the amount of animation or motion it uses.
221        PrefersReducedMotion("sec-ch-prefers-reduced-motion"),
222        /// Sec-CH-Prefers-Reduced-Transparency is used to detect if the user has requested the system minimize the amount of transparent or translucent layer effects it uses.
223        PrefersReducedTransparency("sec-ch-prefers-reduced-transparency"),
224        /// Sec-CH-Prefers-Contrast is used to detect if the user has requested that the web content is presented with a higher (or lower) contrast.
225        PrefersContrast("sec-ch-prefers-contrast"),
226        /// Sec-CH-Forced-Colors is used to detect if the user agent has enabled a forced colors mode where it enforces a user-chosen limited color palette on the page.
227        ForcedColors("sec-ch-forced-colors"),
228    }
229}
230
231// ---------------------------------------------------------------------------
232// Client-hint negotiation headers: a server advertises which [`ClientHint`]s
233// it wants (`Accept-CH`) and which of those are critical (`Critical-CH`).
234//
235// Both are flat comma-separated lists of client-hint header names. Each hint is
236// advertised under *every* spelling it answers to — the preferred `Sec-CH-`
237// form and any legacy bare alias (e.g. both `sec-ch-ect` and `ect`) — so a user
238// agent that recognises only one form still sees its slot. Several deployed
239// hints (the Network Information set `rtt`/`downlink`/`ect`, `device-memory`,
240// `dpr`, `viewport-width`, `save-data`) are only honoured under the bare name,
241// so canonical-only advertisement would silently fail to elicit them. Decoding
242// accepts any known spelling and normalises it back to the [`ClientHint`].
243// ---------------------------------------------------------------------------
244
245/// Encode a hint set as a `", "`-joined list of every header-name spelling each
246/// hint answers to (see [`ClientHint::header_name_strs`]). Allocation-free per
247/// item. The names are static ASCII tokens and the set is non-empty, so this
248/// only ever returns `None` in the unreachable case of an invalid header value.
249fn encode_client_hint_names(hints: &NonEmptySmallVec<16, ClientHint>) -> Option<HeaderValue> {
250    let mut buf = Vec::new();
251    for name in hints
252        .iter()
253        .flat_map(|h| h.header_name_strs().iter().copied())
254    {
255        if !buf.is_empty() {
256            buf.extend_from_slice(b", ");
257        }
258        buf.extend_from_slice(name.as_bytes());
259    }
260    HeaderValue::from_bytes(&buf).ok()
261}
262
263macro_rules! client_hint_advert_header {
264    (
265        #[header(name = $name:ident)]
266        $(#[$m:meta])*
267        $type:ident
268    ) => {
269        $(#[$m])*
270        #[derive(Clone, Debug, PartialEq, Eq)]
271        pub struct $type(pub NonEmptySmallVec<16, ClientHint>);
272
273        impl $type {
274            #[doc = concat!("Create a `", stringify!($type), "` advertising a single [`ClientHint`].")]
275            #[must_use]
276            pub fn new(hint: ClientHint) -> Self {
277                Self(NonEmptySmallVec::new(hint))
278            }
279        }
280
281        impl TypedHeader for $type {
282            fn name() -> &'static HeaderName {
283                &rama_http_types::header::$name
284            }
285        }
286
287        impl HeaderDecode for $type {
288            fn decode<'i, I>(values: &mut I) -> Result<Self, Error>
289            where
290                I: Iterator<Item = &'i HeaderValue>,
291            {
292                util::try_decode_flat_csv_header_values_as_non_empty_smallvec(
293                    values,
294                    util::FlatCsvSeparator::Comma,
295                )
296                .map(Self)
297                .map_err(|err| {
298                    rama_core::telemetry::tracing::debug!(
299                        concat!("failed to decode ", stringify!($name), ": {}"),
300                        err,
301                    );
302                    Error::invalid()
303                })
304            }
305        }
306
307        impl HeaderEncode for $type {
308            fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
309                match encode_client_hint_names(&self.0) {
310                    Some(value) => values.extend(std::iter::once(value)),
311                    None => rama_core::telemetry::tracing::debug!(
312                        concat!("failed to encode ", stringify!($name), " client-hint names")
313                    ),
314                }
315            }
316        }
317    };
318}
319
320client_hint_advert_header! {
321    #[header(name = ACCEPT_CH)]
322    /// `Accept-CH` header, defined in [RFC8942](https://datatracker.ietf.org/doc/html/rfc8942#section-3.1).
323    ///
324    /// Sent by a server to advertise the set of [`ClientHint`]s it would like
325    /// the user agent to send on subsequent requests to the same origin. Each
326    /// hint is advertised under *every* spelling it answers to (its preferred
327    /// `Sec-CH-` form plus any legacy bare alias); entries that do not map to a
328    /// known [`ClientHint`] are rejected on decode.
329    ///
330    /// # ABNF
331    ///
332    /// ```text
333    /// Accept-CH = #client-hint-name
334    /// ```
335    ///
336    /// # Example
337    ///
338    /// ```
339    /// use rama_utils::collections::non_empty_smallvec;
340    /// use rama_http_headers::{AcceptCh, ClientHint};
341    ///
342    /// let accept_ch = AcceptCh(
343    ///     non_empty_smallvec![ClientHint::Ua, ClientHint::Platform, ClientHint::Mobile; 16],
344    /// );
345    /// ```
346    AcceptCh
347}
348
349client_hint_advert_header! {
350    #[header(name = CRITICAL_CH)]
351    /// `Critical-CH` header, defined by the
352    /// [Client Hints Infrastructure](https://wicg.github.io/client-hints-infrastructure/#critical-ch).
353    ///
354    /// Sent alongside [`AcceptCh`] to mark a subset of the advertised
355    /// [`ClientHint`]s as *critical*: if the original request was not sent with
356    /// these hints, a conforming user agent retries the request before handing
357    /// the response to the page. Same wire format as `Accept-CH` (each hint
358    /// under every spelling it answers to).
359    ///
360    /// # ABNF
361    ///
362    /// ```text
363    /// Critical-CH = #client-hint-name
364    /// ```
365    ///
366    /// # Example
367    ///
368    /// ```
369    /// use rama_utils::collections::non_empty_smallvec;
370    /// use rama_http_headers::{ClientHint, CriticalCh};
371    ///
372    /// let critical_ch = CriticalCh(
373    ///     non_empty_smallvec![ClientHint::Ua, ClientHint::Platform; 16],
374    /// );
375    /// ```
376    CriticalCh
377}
378
379// ---------------------------------------------------------------------------
380// Typed value parsers for a subset of the client hints above.
381//
382// Each parses a single header value with a strict spec, following the same
383// typed-header shape rama uses for `Cache-Control`, `Age`, etc. The canonical
384// header name matches the preferred (`Sec-CH-` prefixed) form of the matching
385// [`ClientHint`] variant, sourced from `rama_http_types::header`.
386// ---------------------------------------------------------------------------
387
388/// `Save-Data` client hint: the user agent's preference for reduced data usage.
389///
390/// Defined by the [Save Data API](https://wicg.github.io/savedata/). The header
391/// is sent with the value `on` when the user has opted in to data savings; the
392/// canonical "off" state is the *absence* of the header, though an explicit
393/// `off` is also accepted on decode (both matched ASCII case-insensitively).
394///
395/// Corresponds to [`ClientHint::SaveData`]; encoded as `Sec-CH-Save-Data`.
396///
397/// # Example
398///
399/// ```
400/// use rama_http_headers::SaveData;
401///
402/// let save_data = SaveData::on();
403/// assert!(save_data.is_on());
404/// ```
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
406pub struct SaveData(bool);
407
408impl SaveData {
409    /// The [`ClientHint`] this typed value parser corresponds to.
410    pub const HINT: ClientHint = ClientHint::SaveData;
411
412    /// Create a [`SaveData`] hint from a boolean preference.
413    #[must_use]
414    pub const fn new(enabled: bool) -> Self {
415        Self(enabled)
416    }
417
418    /// Create a [`SaveData`] hint requesting reduced data usage (`on`).
419    #[must_use]
420    pub const fn on() -> Self {
421        Self(true)
422    }
423
424    /// Create a [`SaveData`] hint with data savings disabled (`off`).
425    #[must_use]
426    pub const fn off() -> Self {
427        Self(false)
428    }
429
430    /// Returns `true` if reduced data usage is requested.
431    #[must_use]
432    pub const fn is_on(self) -> bool {
433        self.0
434    }
435}
436
437impl From<bool> for SaveData {
438    fn from(enabled: bool) -> Self {
439        Self(enabled)
440    }
441}
442
443impl From<SaveData> for bool {
444    fn from(value: SaveData) -> Self {
445        value.0
446    }
447}
448
449impl TypedHeader for SaveData {
450    fn name() -> &'static HeaderName {
451        &rama_http_types::header::SEC_CH_SAVE_DATA
452    }
453}
454
455impl HeaderDecode for SaveData {
456    fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
457        let value = values
458            .just_one()
459            .and_then(|value| value.to_str().ok())
460            .ok_or_else(Error::invalid)?;
461        if value.eq_ignore_ascii_case("on") {
462            Ok(Self(true))
463        } else if value.eq_ignore_ascii_case("off") {
464            Ok(Self(false))
465        } else {
466            Err(Error::invalid())
467        }
468    }
469}
470
471impl HeaderEncode for SaveData {
472    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
473        let value = if self.0 {
474            HeaderValue::from_static("on")
475        } else {
476            HeaderValue::from_static("off")
477        };
478        values.extend(std::iter::once(value));
479    }
480}
481
482/// `Sec-CH-ECT` (Effective Connection Type) client hint.
483///
484/// Describes the measured network performance as one of an enumerated set of
485/// connection profiles, each covering a range of [`Rtt`] and [`Downlink`]
486/// values. See the
487/// [Network Information API](https://wicg.github.io/netinfo/#dom-effectiveconnectiontype).
488///
489/// Corresponds to [`ClientHint::Ect`]; encoded as `Sec-CH-ECT`.
490///
491/// # Example
492///
493/// ```
494/// use rama_http_headers::Ect;
495///
496/// assert_eq!(Ect::Type4g.as_str(), "4g");
497/// ```
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
499pub enum Ect {
500    /// `slow-2g`
501    Slow2g,
502    /// `2g`
503    Type2g,
504    /// `3g`
505    Type3g,
506    /// `4g`
507    Type4g,
508}
509
510impl Ect {
511    /// The [`ClientHint`] this typed value parser corresponds to.
512    pub const HINT: ClientHint = ClientHint::Ect;
513
514    /// Returns the canonical wire representation of this connection type.
515    #[must_use]
516    pub const fn as_str(self) -> &'static str {
517        match self {
518            Self::Slow2g => "slow-2g",
519            Self::Type2g => "2g",
520            Self::Type3g => "3g",
521            Self::Type4g => "4g",
522        }
523    }
524}
525
526impl std::fmt::Display for Ect {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        f.write_str(self.as_str())
529    }
530}
531
532impl TypedHeader for Ect {
533    fn name() -> &'static HeaderName {
534        &rama_http_types::header::SEC_CH_ECT
535    }
536}
537
538impl HeaderDecode for Ect {
539    fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
540        let value = values
541            .just_one()
542            .and_then(|value| value.to_str().ok())
543            .ok_or_else(Error::invalid)?;
544        rama_utils::macros::match_ignore_ascii_case_str! {
545            match (value) {
546                "slow-2g" => Ok(Self::Slow2g),
547                "2g" => Ok(Self::Type2g),
548                "3g" => Ok(Self::Type3g),
549                "4g" => Ok(Self::Type4g),
550                _ => Err(Error::invalid()),
551            }
552        }
553    }
554}
555
556impl HeaderEncode for Ect {
557    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
558        values.extend(std::iter::once(HeaderValue::from_static(self.as_str())));
559    }
560}
561
562/// `Sec-CH-RTT` client hint: the approximate round-trip time on the application
563/// layer, modelled as a [`Duration`].
564///
565/// Unlike transport-layer RTT this includes server processing time. The value
566/// is rounded to the nearest 25 ms to limit fingerprinting. See the
567/// [Network Information API](https://wicg.github.io/netinfo/#dom-networkinformation-rtt).
568///
569/// Corresponds to [`ClientHint::Rtt`]; encoded as `Sec-CH-RTT`.
570///
571/// # Example
572///
573/// ```
574/// use std::time::Duration;
575/// use rama_http_headers::Rtt;
576///
577/// let rtt = Rtt::from_millis(100);
578/// assert_eq!(Duration::from(rtt), Duration::from_millis(100));
579/// ```
580#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
581pub struct Rtt(u64);
582
583impl Rtt {
584    /// The [`ClientHint`] this typed value parser corresponds to.
585    pub const HINT: ClientHint = ClientHint::Rtt;
586
587    /// Create an [`Rtt`] hint from a round-trip time in milliseconds.
588    #[must_use]
589    pub const fn from_millis(millis: u64) -> Self {
590        Self(millis)
591    }
592
593    /// Returns the round-trip time in milliseconds.
594    #[must_use]
595    pub const fn as_millis(self) -> u64 {
596        self.0
597    }
598
599    /// Returns the round-trip time as a [`Duration`].
600    #[must_use]
601    pub const fn as_duration(self) -> Duration {
602        Duration::from_millis(self.0)
603    }
604}
605
606impl From<Rtt> for Duration {
607    fn from(rtt: Rtt) -> Self {
608        rtt.as_duration()
609    }
610}
611
612impl TypedHeader for Rtt {
613    fn name() -> &'static HeaderName {
614        &rama_http_types::header::SEC_CH_RTT
615    }
616}
617
618impl HeaderDecode for Rtt {
619    fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
620        values
621            .just_one()
622            .and_then(|value| value.to_str().ok())
623            .and_then(|s| s.parse::<u64>().ok())
624            .map(Self)
625            .ok_or_else(Error::invalid)
626    }
627}
628
629impl HeaderEncode for Rtt {
630    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
631        values.extend(std::iter::once(self.0.into()));
632    }
633}
634
635/// `Sec-CH-Downlink` client hint: the approximate downstream speed of the
636/// user's connection, in megabits per second (Mbps).
637///
638/// The value is rounded to the nearest 25 kbps to limit fingerprinting. See the
639/// [Network Information API](https://wicg.github.io/netinfo/#dom-networkinformation-downlink).
640///
641/// Corresponds to [`ClientHint::Downlink`]; encoded as `Sec-CH-Downlink`.
642///
643/// # Example
644///
645/// ```
646/// use rama_http_headers::Downlink;
647///
648/// let downlink = Downlink::new(1.6);
649/// assert_eq!(downlink.as_mbps(), 1.6);
650/// ```
651#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
652pub struct Downlink(f64);
653
654impl Downlink {
655    /// The [`ClientHint`] this typed value parser corresponds to.
656    pub const HINT: ClientHint = ClientHint::Downlink;
657
658    /// Create a [`Downlink`] hint from a speed in megabits per second.
659    #[must_use]
660    pub const fn new(mbps: f64) -> Self {
661        Self(mbps)
662    }
663
664    /// Returns the downstream speed in megabits per second.
665    #[must_use]
666    pub const fn as_mbps(self) -> f64 {
667        self.0
668    }
669}
670
671impl From<Downlink> for f64 {
672    fn from(downlink: Downlink) -> Self {
673        downlink.0
674    }
675}
676
677impl TypedHeader for Downlink {
678    fn name() -> &'static HeaderName {
679        &rama_http_types::header::SEC_CH_DOWNLINK
680    }
681}
682
683impl HeaderDecode for Downlink {
684    fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
685        values
686            .just_one()
687            .and_then(|value| value.to_str().ok())
688            .and_then(|s| s.parse::<f64>().ok())
689            .filter(|mbps| mbps.is_finite() && *mbps >= 0.0)
690            .map(Self)
691            .ok_or_else(Error::invalid)
692    }
693}
694
695impl HeaderEncode for Downlink {
696    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
697        values.extend(std::iter::once(util::fmt(self.0)));
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    #[test]
706    fn test_client_hint_ua_from_str() {
707        let hint = ClientHint::try_from("Sec-CH-UA").unwrap();
708        assert_eq!(hint, ClientHint::Ua);
709    }
710
711    #[test]
712    fn test_client_hint_ua_from_str_lowercase() {
713        let hint = ClientHint::try_from("sec-ch-ua").unwrap();
714        assert_eq!(hint, ClientHint::Ua);
715    }
716
717    #[test]
718    fn test_client_hint_ua_from_str_uppercase() {
719        let hint = ClientHint::try_from("SEC-CH-UA").unwrap();
720        assert_eq!(hint, ClientHint::Ua);
721    }
722
723    #[test]
724    fn test_client_hint_ua_from_str_mixedcase() {
725        let hint = ClientHint::try_from("Sec-CH-UA").unwrap();
726        assert_eq!(hint, ClientHint::Ua);
727    }
728
729    #[test]
730    fn test_client_hint_low_entropy() {
731        let hints = [
732            "Sec-CH-UA",
733            "Sec-CH-UA-Mobile",
734            "Sec-CH-UA-Platform",
735            "Save-Data",
736            "Sec-CH-Save-Data",
737        ];
738
739        for hint in hints {
740            let hint = ClientHint::try_from(hint).expect(hint);
741            assert!(hint.is_low_entropy());
742        }
743    }
744
745    #[test]
746    fn test_client_hint_high_entropy() {
747        let hints = [
748            "Sec-CH-UA-Full-Version",
749            "Sec-CH-UA-Full-Version-List",
750            "Sec-CH-UA-Platform-Version",
751            "Sec-CH-UA-Arch",
752            "Sec-CH-UA-Bitness",
753            "Sec-CH-UA-WoW64",
754            "Sec-CH-UA-Model",
755            "Sec-CH-UA-Form-Factors",
756            "Sec-CH-Width",
757            "Sec-CH-Viewport-Width",
758            "Sec-CH-Viewport-Height",
759            "Sec-CH-DPR",
760            "Sec-CH-Device-Memory",
761            "Sec-CH-RTT",
762            "Sec-CH-Downlink",
763            "Sec-CH-ECT",
764            "Sec-CH-Prefers-Color-Scheme",
765            "Sec-CH-Prefers-Reduced-Motion",
766            "Sec-CH-Prefers-Reduced-Transparency",
767            "Sec-CH-Prefers-Contrast",
768            "Sec-CH-Forced-Colors",
769        ];
770
771        for hint in hints {
772            let hint = ClientHint::try_from(hint).expect(hint);
773            assert!(!hint.is_low_entropy());
774        }
775    }
776
777    #[test]
778    fn test_all_client_hint_header_name_strings_contains_some_hints() {
779        let strings = all_client_hint_header_name_strings().collect::<Vec<_>>();
780        assert!(strings.contains(&"sec-ch-ua"), "{strings:?}");
781    }
782
783    #[test]
784    fn test_all_client_hint_header_names() {
785        let names = all_client_hint_header_names().collect::<Vec<_>>();
786        let strings = all_client_hint_header_name_strings().collect::<Vec<_>>();
787        assert_eq!(names.len(), strings.len());
788        for (name, string) in names.iter().zip(strings.iter()) {
789            assert_eq!(name.as_str(), *string);
790        }
791    }
792
793    fn decode<T: HeaderDecode>(values: &[&str]) -> Option<T> {
794        use crate::HeaderMapExt;
795        let mut map = rama_http_types::HeaderMap::new();
796        for value in values {
797            map.append(T::name(), value.parse().unwrap());
798        }
799        map.typed_get()
800    }
801
802    fn encode<T: HeaderEncode>(header: T) -> String {
803        use crate::HeaderMapExt;
804        let mut map = rama_http_types::HeaderMap::new();
805        map.typed_insert(header);
806        map.get(T::name())
807            .expect("header set")
808            .to_str()
809            .unwrap()
810            .to_owned()
811    }
812
813    #[test]
814    fn test_save_data_decode() {
815        assert_eq!(decode::<SaveData>(&["on"]), Some(SaveData::on()));
816        assert_eq!(decode::<SaveData>(&["ON"]), Some(SaveData::on()));
817        assert_eq!(decode::<SaveData>(&["off"]), Some(SaveData::off()));
818        assert!(decode::<SaveData>(&["1"]).is_none());
819        assert!(decode::<SaveData>(&[""]).is_none());
820        assert!(decode::<SaveData>(&["on", "off"]).is_none());
821    }
822
823    #[test]
824    fn test_save_data_round_trip() {
825        assert_eq!(encode(SaveData::on()), "on");
826        assert_eq!(encode(SaveData::off()), "off");
827        assert_eq!(
828            decode::<SaveData>(&[encode(SaveData::on()).as_str()]),
829            Some(SaveData::on())
830        );
831    }
832
833    #[test]
834    fn test_ect_decode() {
835        assert_eq!(decode::<Ect>(&["slow-2g"]), Some(Ect::Slow2g));
836        assert_eq!(decode::<Ect>(&["2g"]), Some(Ect::Type2g));
837        assert_eq!(decode::<Ect>(&["3g"]), Some(Ect::Type3g));
838        assert_eq!(decode::<Ect>(&["4g"]), Some(Ect::Type4g));
839        assert_eq!(decode::<Ect>(&["SLOW-2G"]), Some(Ect::Slow2g));
840        assert!(decode::<Ect>(&["5g"]).is_none());
841        assert!(decode::<Ect>(&[""]).is_none());
842    }
843
844    #[test]
845    fn test_ect_round_trip() {
846        for ect in [Ect::Slow2g, Ect::Type2g, Ect::Type3g, Ect::Type4g] {
847            assert_eq!(decode::<Ect>(&[encode(ect).as_str()]), Some(ect));
848        }
849    }
850
851    #[test]
852    fn test_rtt_decode() {
853        assert_eq!(
854            decode::<Rtt>(&["100"]).map(Duration::from),
855            Some(Duration::from_millis(100)),
856        );
857        assert_eq!(decode::<Rtt>(&["0"]), Some(Rtt::from_millis(0)));
858        assert!(decode::<Rtt>(&["-25"]).is_none());
859        assert!(decode::<Rtt>(&["1.5"]).is_none());
860        assert!(decode::<Rtt>(&["fast"]).is_none());
861    }
862
863    #[test]
864    fn test_rtt_round_trip() {
865        assert_eq!(encode(Rtt::from_millis(125)), "125");
866        assert_eq!(decode::<Rtt>(&["125"]), Some(Rtt::from_millis(125)));
867    }
868
869    #[test]
870    fn test_downlink_decode() {
871        assert_eq!(decode::<Downlink>(&["1.5"]), Some(Downlink::new(1.5)));
872        assert_eq!(decode::<Downlink>(&["100"]), Some(Downlink::new(100.0)));
873        assert_eq!(decode::<Downlink>(&["0"]), Some(Downlink::new(0.0)));
874        assert!(decode::<Downlink>(&["-1"]).is_none());
875        assert!(decode::<Downlink>(&["inf"]).is_none());
876        assert!(decode::<Downlink>(&["fast"]).is_none());
877    }
878
879    #[test]
880    fn test_downlink_round_trip() {
881        assert_eq!(encode(Downlink::new(1.5)), "1.5");
882        assert_eq!(encode(Downlink::new(100.0)), "100");
883        assert_eq!(decode::<Downlink>(&["1.5"]), Some(Downlink::new(1.5)));
884    }
885
886    #[test]
887    fn test_accept_ch_decode() {
888        use rama_utils::collections::non_empty_smallvec;
889
890        assert_eq!(
891            decode::<AcceptCh>(&["sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile"]),
892            Some(AcceptCh(non_empty_smallvec![
893                ClientHint::Ua,
894                ClientHint::Platform,
895                ClientHint::Mobile; 16
896            ])),
897        );
898        // case-insensitive and legacy aliases both map onto the canonical hint
899        assert_eq!(
900            decode::<AcceptCh>(&["DPR, save-data"]),
901            Some(AcceptCh(non_empty_smallvec![
902                ClientHint::Dpr,
903                ClientHint::SaveData; 16
904            ])),
905        );
906        // multiple header lines fold into a single list
907        assert_eq!(
908            decode::<AcceptCh>(&["sec-ch-ua", "sec-ch-ua-platform"]),
909            Some(AcceptCh(non_empty_smallvec![
910                ClientHint::Ua,
911                ClientHint::Platform; 16
912            ])),
913        );
914        // an unknown hint poisons the whole list
915        assert!(decode::<AcceptCh>(&["sec-ch-ua, not-a-hint"]).is_none());
916        assert!(decode::<AcceptCh>(&[""]).is_none());
917        assert!(decode::<AcceptCh>(&[]).is_none());
918    }
919
920    #[test]
921    fn test_accept_ch_round_trip() {
922        use rama_utils::collections::non_empty_smallvec;
923
924        let header = AcceptCh(non_empty_smallvec![
925            ClientHint::Ua,
926            ClientHint::Platform,
927            ClientHint::Mobile; 16
928        ]);
929        assert_eq!(
930            encode(header.clone()),
931            "sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile",
932        );
933        assert_eq!(
934            decode::<AcceptCh>(&[encode(header.clone()).as_str()]),
935            Some(header)
936        );
937    }
938
939    #[test]
940    fn test_critical_ch_round_trip() {
941        use rama_utils::collections::non_empty_smallvec;
942
943        let header = CriticalCh(non_empty_smallvec![ClientHint::Ua, ClientHint::Platform; 16]);
944        assert_eq!(encode(header.clone()), "sec-ch-ua, sec-ch-ua-platform");
945        assert_eq!(
946            decode::<CriticalCh>(&[encode(header.clone()).as_str()]),
947            Some(header),
948        );
949    }
950
951    #[test]
952    fn test_header_name_strs() {
953        // dual-name hints expose the Sec-CH-* form first, then the bare alias
954        assert_eq!(ClientHint::Ect.header_name_strs(), &["sec-ch-ect", "ect"]);
955        assert_eq!(
956            ClientHint::SaveData.header_name_strs(),
957            &["sec-ch-save-data", "save-data"]
958        );
959        // single-name hints expose just the one
960        assert_eq!(ClientHint::Ua.header_name_strs(), &["sec-ch-ua"]);
961        // `as_str` is always the first (preferred) spelling
962        for hint in all_client_hints() {
963            assert_eq!(hint.as_str(), hint.header_name_strs()[0]);
964            // `iter_header_names` agrees with the str slice
965            let names: Vec<_> = hint
966                .iter_header_names()
967                .map(|n| n.as_str().to_owned())
968                .collect();
969            assert_eq!(names, hint.header_name_strs());
970        }
971    }
972
973    #[test]
974    fn test_accept_ch_emits_all_aliases() {
975        use rama_utils::collections::non_empty_smallvec;
976
977        // every dual-name hint is advertised under BOTH spellings, so a UA that
978        // only honours the legacy bare name (e.g. Chrome for the network hints)
979        // still sees its slot.
980        let header = AcceptCh(non_empty_smallvec![
981            ClientHint::SaveData,
982            ClientHint::Ect,
983            ClientHint::Rtt,
984            ClientHint::Downlink; 16
985        ]);
986        assert_eq!(
987            encode(header),
988            "sec-ch-save-data, save-data, sec-ch-ect, ect, \
989             sec-ch-rtt, rtt, sec-ch-downlink, downlink",
990        );
991        // and decoding any spelling round-trips back to the hint set
992        assert_eq!(
993            decode::<AcceptCh>(&["save-data, ect, rtt, downlink"]),
994            Some(AcceptCh(non_empty_smallvec![
995                ClientHint::SaveData,
996                ClientHint::Ect,
997                ClientHint::Rtt,
998                ClientHint::Downlink; 16
999            ])),
1000        );
1001    }
1002
1003    #[test]
1004    fn test_critical_ch_emits_all_aliases() {
1005        let header = CriticalCh::new(ClientHint::SaveData);
1006        assert_eq!(encode(header), "sec-ch-save-data, save-data");
1007    }
1008
1009    #[test]
1010    fn test_typed_value_parsers_match_their_client_hint() {
1011        assert_eq!(SaveData::HINT, ClientHint::SaveData);
1012        assert_eq!(Ect::HINT, ClientHint::Ect);
1013        assert_eq!(Rtt::HINT, ClientHint::Rtt);
1014        assert_eq!(Downlink::HINT, ClientHint::Downlink);
1015
1016        // the typed header name must agree with the hint's preferred name
1017        assert_eq!(SaveData::name().as_str(), SaveData::HINT.as_str());
1018        assert_eq!(Ect::name().as_str(), Ect::HINT.as_str());
1019        assert_eq!(Rtt::name().as_str(), Rtt::HINT.as_str());
1020        assert_eq!(Downlink::name().as_str(), Downlink::HINT.as_str());
1021    }
1022}