leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::LazyLock;

use serde::Deserialize;

include!(concat!(env!("OUT_DIR"), "/header_style.rs"));

mod kind;
pub use kind::Preset;

pub struct HeaderContext<'a> {
    pub user_agent: &'a str,
    pub sec_ch_ua: &'a str,
    pub sec_ch_ua_mobile: &'a str,
    pub sec_ch_ua_platform: &'a str,
    pub accept_language: &'a str,
    pub origin: &'a str,
    pub referer: &'a str,
    pub fetch_site: &'a str,
    pub navigation_site: &'a str,
    pub navigation_referer: &'a str,
}

pub type HeaderPair = (Cow<'static, str>, Cow<'static, str>);

type HeaderTemplate = Vec<(String, String)>;

#[derive(Debug, Deserialize)]
struct ShapeRow {
    fallback: Option<HeaderTemplate>,
    #[serde(default)]
    presets: HashMap<Preset, HeaderTemplate>,
    extends: Option<HeaderStyle>,
    #[serde(default)]
    append: HeaderTemplate,
    order: Option<Vec<String>>,
}

#[derive(Debug)]
struct HeaderShape {
    fallback: HeaderTemplate,
    presets: HashMap<Preset, HeaderTemplate>,
    append: HeaderTemplate,
    order: Option<Vec<String>>,
}

static SHAPES: LazyLock<HashMap<HeaderStyle, HeaderShape>> = LazyLock::new(|| {
    let rows: HashMap<HeaderStyle, ShapeRow> =
        toml::from_str(include_str!("../../profiles/headers.toml"))
            .expect("built-in header table is statically valid");
    rows.keys()
        .map(|&style| (style, resolve_shape(&rows, style)))
        .collect()
});

fn resolve_shape(rows: &HashMap<HeaderStyle, ShapeRow>, style: HeaderStyle) -> HeaderShape {
    let row = rows
        .get(&style)
        .expect("build.rs checks every extends target");
    let base = row.extends.map(|parent| resolve_shape(rows, parent));
    let mut presets = base
        .as_ref()
        .map(|base| base.presets.clone())
        .unwrap_or_default();
    presets.extend(row.presets.clone());
    let fallback = row
        .fallback
        .clone()
        .or_else(|| base.map(|base| base.fallback))
        .expect("build.rs checks every shape reaches a fallback");
    HeaderShape {
        fallback,
        presets,
        append: row.append.clone(),
        order: row.order.clone(),
    }
}

impl HeaderContext<'_> {
    fn placeholders(&self) -> [(&'static str, &str); 10] {
        [
            ("{user_agent}", self.user_agent),
            ("{sec_ch_ua}", self.sec_ch_ua),
            ("{sec_ch_ua_mobile}", self.sec_ch_ua_mobile),
            ("{sec_ch_ua_platform}", self.sec_ch_ua_platform),
            ("{accept_language}", self.accept_language),
            ("{origin}", self.origin),
            ("{referer}", self.referer),
            ("{fetch_site}", self.fetch_site),
            ("{navigation_site}", self.navigation_site),
            ("{navigation_referer}", self.navigation_referer),
        ]
    }

    fn expand(&self, template: &'static str) -> Option<Cow<'static, str>> {
        if !template.contains('{') {
            return Some(Cow::Borrowed(template));
        }
        let placeholders = self.placeholders();
        let mut value = String::with_capacity(template.len());
        let mut filled = false;
        let mut seen = false;
        let mut rest = template;
        while let Some(start) = rest.find('{') {
            value.push_str(&rest[..start]);
            rest = &rest[start..];
            match placeholders.iter().find(|(key, _)| rest.starts_with(key)) {
                Some((key, replacement)) => {
                    seen = true;
                    filled |= !replacement.is_empty();
                    value.push_str(replacement);
                    rest = &rest[key.len()..];
                }
                None => {
                    value.push('{');
                    rest = &rest[1..];
                }
            }
        }
        value.push_str(rest);
        (filled || !seen).then_some(Cow::Owned(value))
    }
}

impl HeaderStyle {
    fn shape(self) -> &'static HeaderShape {
        SHAPES
            .get(&self)
            .expect("built-in header table covers every header style")
    }

    pub(crate) fn append(self) -> &'static [(String, String)] {
        &self.shape().append
    }

    pub(crate) fn order(self) -> Option<&'static [String]> {
        self.shape().order.as_deref()
    }

    pub(crate) fn build_headers(
        self,
        preset: Option<Preset>,
        ctx: &HeaderContext<'_>,
    ) -> Vec<HeaderPair> {
        let shape = self.shape();
        let template = preset
            .and_then(|preset| shape.presets.get(&preset))
            .unwrap_or(&shape.fallback);
        template
            .iter()
            .filter_map(|(name, value)| {
                ctx.expand(value.as_str())
                    .map(|rendered| (Cow::Borrowed(name.as_str()), rendered))
            })
            .collect()
    }
}

#[cfg(test)]
mod tests;