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::sync::{Arc, Mutex, PoisonError};

use http::Method;
use url::Url;

use crate::core::error::{Error, Kind, Result};
use crate::core::into_url::IntoUrl;
use crate::core::request::{IntoParamPair, RequestBuilder};
use crate::core::response::Response;
use crate::core::session::Session;
use crate::profile::Preset;

#[derive(Clone)]
pub struct Tab {
    session: Session,
    page: Arc<Mutex<Option<Url>>>,
}

impl std::fmt::Debug for Tab {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Tab")
            .field("current", &self.current())
            .finish_non_exhaustive()
    }
}

impl Tab {
    pub(crate) fn new(session: Session) -> Self {
        Self {
            session,
            page: Arc::new(Mutex::new(None)),
        }
    }

    pub fn session(&self) -> &Session {
        &self.session
    }

    pub fn current(&self) -> Option<Url> {
        self.page
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .clone()
    }

    pub fn set_current(&self, page: Option<Url>) {
        *self.page.lock().unwrap_or_else(PoisonError::into_inner) = page;
    }

    pub(crate) fn request(
        &self,
        method: Method,
        url: impl IntoUrl,
        preset: Preset,
    ) -> RequestBuilder {
        let page = self.current();
        let builder = self.build(method, url, page.as_ref()).preset(preset);
        match page {
            Some(page) => builder.initiator(page),
            None => builder,
        }
    }

    pub fn fetch(&self, method: Method, url: impl IntoUrl) -> RequestBuilder {
        self.script(method, url, Preset::Xhr)
    }

    pub fn xhr(&self, url: impl IntoUrl) -> RequestBuilder {
        self.fetch(Method::GET, url)
    }

    pub fn post_json(&self, url: impl IntoUrl, body: &impl serde::Serialize) -> RequestBuilder {
        self.fetch(Method::POST, url).json(body)
    }

    pub fn subresource(&self, url: impl IntoUrl, preset: Preset) -> RequestBuilder {
        self.script(Method::GET, url, preset)
    }

    fn script(&self, method: Method, url: impl IntoUrl, preset: Preset) -> RequestBuilder {
        let mut builder = self.request(method, url, preset);
        if self.current().is_none() {
            builder.fail(
                Error::new(Kind::Request).with_message("the tab has no page; open one first"),
            );
        }
        builder
    }

    pub async fn open(&self, url: impl IntoUrl) -> Result<Response> {
        let page = self.current();
        let builder = self
            .build(Method::GET, url, page.as_ref())
            .preset(Preset::Navigate);
        self.navigate(builder).await
    }

    pub async fn follow(&self, url: impl IntoUrl) -> Result<Response> {
        self.navigate(self.trusted(self.request(Method::GET, url, Preset::Navigate)))
            .await
    }

    pub async fn submit<I, P>(&self, url: impl IntoUrl, fields: I) -> Result<Response>
    where
        I: IntoIterator<Item = P>,
        P: IntoParamPair,
    {
        let builder = self
            .request(Method::POST, url, Preset::FormNavigate)
            .form(fields);
        self.navigate(builder).await
    }

    #[cfg(feature = "html")]
    pub async fn submit_form(&self, form: &crate::html::Form) -> Result<Response> {
        let mut url = self.form_action(form)?;
        let fields = normalized_fields(form);
        let builder = match form.method() {
            crate::html::FormMethod::Post => encode_form(
                self.request(Method::POST, url, Preset::FormNavigate),
                form.enctype(),
                fields,
            ),
            crate::html::FormMethod::Get => {
                url.query_pairs_mut().clear().extend_pairs(&fields);
                self.request(Method::GET, url, Preset::Navigate)
            }
        };
        self.navigate(self.trusted(builder)).await
    }

    #[cfg(feature = "html")]
    fn form_action(&self, form: &crate::html::Form) -> Result<Url> {
        let page = self.current().or_else(|| self.session.base_url().cloned());
        let base = form
            .base()
            .and_then(|href| href.into_url_with_base(page.as_ref()).ok())
            .or_else(|| page.clone());
        let against = if form.action().is_empty() {
            page.as_ref()
        } else {
            base.as_ref()
        };
        form.action().into_url_with_base(against)
    }

    pub(crate) async fn navigate(&self, builder: RequestBuilder) -> Result<Response> {
        let response = builder.send().await?;
        self.set_current(Some(response.url().clone()));
        Ok(response)
    }

    fn trusted(&self, mut builder: RequestBuilder) -> RequestBuilder {
        builder.trusted_origin = self.current();
        builder
    }

    fn build(&self, method: Method, url: impl IntoUrl, page: Option<&Url>) -> RequestBuilder {
        let Some(page) = page else {
            return self.session.request(method, url);
        };
        match url.into_url_with_base(Some(page)) {
            Ok(url) => self.session.request(method, url),
            Err(err) => {
                let mut builder = self.session.request(method, page.clone());
                builder.fail(err);
                builder
            }
        }
    }
}

#[cfg(feature = "html")]
fn normalized_fields(form: &crate::html::Form) -> Vec<(String, String)> {
    form.fields()
        .iter()
        .map(|(name, value)| (crlf(name), crlf(value)))
        .collect()
}

#[cfg(feature = "html")]
fn crlf(text: &str) -> String {
    text.replace("\r\n", "\n")
        .replace('\r', "\n")
        .replace('\n', "\r\n")
}

#[cfg(feature = "html")]
fn encode_form(
    builder: RequestBuilder,
    enctype: crate::html::FormEnctype,
    fields: Vec<(String, String)>,
) -> RequestBuilder {
    use crate::html::FormEnctype;
    match enctype {
        FormEnctype::UrlEncoded => builder.form(fields),
        FormEnctype::TextPlain => {
            let body: String = fields
                .iter()
                .map(|(name, value)| format!("{name}={value}\r\n"))
                .collect();
            builder
                .header(http::header::CONTENT_TYPE, FormEnctype::TextPlain.as_str())
                .body(body)
        }
        FormEnctype::Multipart => builder.multipart(fields.into_iter().fold(
            crate::core::multipart::Form::new(),
            |body, (name, value)| body.text(name, value),
        )),
    }
}