leyline-http 0.1.1

HTTP client that mimics browsers on the wire
Documentation
use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, ready};

use futures_util::Stream;
use http::Method;
use url::Url;

use crate::core::config::{Origin, ProxyConfig, RedirectPolicy, TimeoutConfig};
use crate::core::digest::DigestAuth;
use crate::core::error::Result;
use crate::core::headers::HeaderList;
use crate::core::request::RequestBuilder;
use crate::core::response::Response;
use crate::core::retry::RetryPolicy;
use crate::core::session::Session;
use crate::profile::Preset;
use crate::util::sensitive_header;

const NEXT: &str = "next";

type PageFuture = Pin<Box<dyn Future<Output = Result<Response>> + Send>>;

#[must_use = "pages are lazy: nothing happens until `.next()` is awaited"]
pub struct Pages {
    template: Template,
    pending: Option<PageFuture>,
    visited: HashSet<Url>,
    remaining: Option<usize>,
}

struct Template {
    session: Session,
    first: Option<Url>,
    preset: Option<Preset>,
    preset_user: bool,
    headers: HeaderList,
    timeouts: Option<TimeoutConfig>,
    stream_response: bool,
    retry_policy: RetryPolicy,
    digest_auth: Option<DigestAuth>,
    proxy: Option<ProxyConfig>,
    header_order: Option<Vec<String>>,
    redirect: Option<RedirectPolicy>,
    initiator: Option<Url>,
    tag: Option<String>,
    status_errors: bool,
}

impl RequestBuilder {
    pub fn pages(self) -> Pages {
        let template = Template::of(&self);
        let visited = template.first.iter().cloned().collect();
        Pages {
            template,
            pending: Some(Box::pin(self.send())),
            visited,
            remaining: None,
        }
    }
}

impl Template {
    fn of(first: &RequestBuilder) -> Self {
        Self {
            session: first.session.clone(),
            first: Url::parse(&first.url).ok(),
            preset: first.preset,
            preset_user: first.preset_user,
            headers: first.headers.clone(),
            timeouts: first.timeouts,
            stream_response: first.stream_response,
            retry_policy: first.retry_policy.clone(),
            digest_auth: first.digest_auth.clone(),
            proxy: first.proxy.clone(),
            header_order: first.header_order.clone(),
            redirect: first.redirect.clone(),
            initiator: first.initiator.clone(),
            tag: first.tag.clone(),
            status_errors: first.status_errors,
        }
    }

    fn downgrades(&self, url: &Url) -> bool {
        self.first
            .as_ref()
            .is_some_and(|first| first.scheme() == "https" && url.scheme() == "http")
    }

    fn same_origin(&self, url: &Url) -> bool {
        let first = self.first.as_ref().and_then(Origin::of);
        first.is_some() && first == Origin::of(url)
    }

    fn follow(&self, url: &Url) -> RequestBuilder {
        let mut next = RequestBuilder::new(&self.session, Method::GET, url.as_str());
        if self.preset_user {
            next.preset = self.preset;
            next.preset_user = true;
        }
        next.headers = self.headers.clone();
        next.timeouts = self.timeouts;
        next.stream_response = self.stream_response;
        next.retry_policy = self.retry_policy.clone();
        if self.same_origin(url) {
            next.digest_auth = self.digest_auth.clone();
        } else {
            next.headers
                .remove_where(|name| sensitive_header(name.as_str()));
        }
        next.proxy = self.proxy.clone();
        next.header_order = self.header_order.clone();
        next.redirect = self.redirect.clone();
        next.initiator = self.initiator.clone();
        next.tag = self.tag.clone();
        next.status_errors = self.status_errors;
        next.trusted_origin = self.first.clone();
        next
    }
}

impl Pages {
    pub fn limit(mut self, max: usize) -> Self {
        self.remaining = Some(max);
        if max == 0 {
            self.pending = None;
        }
        self
    }

    pub async fn next(&mut self) -> Option<Result<Response>> {
        std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await
    }

    fn queue_next(&mut self, response: &Response) {
        self.visited.insert(response.url().clone());
        let Some(url) = response.link(NEXT) else {
            return;
        };
        if self.template.downgrades(&url) {
            return;
        }
        if self.visited.insert(url.clone()) {
            self.pending = Some(Box::pin(self.template.follow(&url).send()));
        }
    }
}

impl Stream for Pages {
    type Item = Result<Response>;

    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        let this = self.get_mut();
        let Some(pending) = this.pending.as_mut() else {
            return Poll::Ready(None);
        };
        let result = ready!(pending.as_mut().poll(cx));
        this.pending = None;
        if let Some(remaining) = this.remaining.as_mut() {
            *remaining = remaining.saturating_sub(1);
        }
        if let (Ok(response), false) = (&result, this.remaining == Some(0)) {
            this.queue_next(response);
        }
        Poll::Ready(Some(result))
    }
}

impl std::fmt::Debug for Pages {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Pages")
            .field("visited", &self.visited.len())
            .field("done", &self.pending.is_none())
            .finish()
    }
}