ugi 0.2.1

Runtime-agnostic Rust request client with HTTP/1.1, HTTP/2, HTTP/3, H2C, WebSocket, SSE, and gRPC support
Documentation
#![cfg(feature = "emulation")]

use std::thread;

use async_net::TcpListener;
use futures_lite::future::block_on;
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
use ugi::{Client, Emulation, EmulationProfile};

fn run<T>(value: T) -> T::Output
where
    T: std::future::IntoFuture,
{
    block_on(async move { value.await })
}

#[test]
fn preset_emulation_applies_default_headers_end_to_end() {
    let base = block_on(spawn_http_assert_server(|request| {
        let request = request.to_ascii_lowercase();
        assert!(request.contains("get /profile http/1.1\r\n"));
        assert!(request.contains("\r\nuser-agent: mozilla/5.0 (macintosh; intel mac os x 10_15_7) applewebkit/605.1.15 (khtml, like gecko) version/18.4 safari/605.1.15\r\n"));
        assert!(request.contains("\r\naccept-language: en-us,en;q=0.9\r\n"));
    }))
    .unwrap();

    let client = Client::builder()
        .emulation(Emulation::Safari18_4)
        .http1_only()
        .build()
        .unwrap();

    let response = run(client.get(format!("{base}/profile"))).unwrap();
    assert_eq!(block_on(response.text()).unwrap(), "ok");
}

#[test]
fn request_level_emulation_replaces_client_profile_without_leaking_defaults() {
    let base = block_on(spawn_http_assert_server(|request| {
        let request = request.to_ascii_lowercase();
        assert!(request.contains("get /override http/1.1\r\n"));
        assert!(request.contains("\r\nuser-agent: customagent/1.0\r\n"));
        assert!(request.contains("\r\nx-emulation: request\r\n"));
        assert!(!request.contains("chrome/136.0.0.0"));
        assert!(!request.contains("\r\naccept-language:"));
    }))
    .unwrap();

    let profile = EmulationProfile::builder()
        .default_header("user-agent", "CustomAgent/1.0")
        .unwrap()
        .default_header("x-emulation", "request")
        .unwrap()
        .build();

    let client = Client::builder()
        .base_url(&base)
        .unwrap()
        .emulation(Emulation::Chrome136)
        .http1_only()
        .build()
        .unwrap();

    let response = run(client.get("/override").emulation(profile)).unwrap();
    assert_eq!(block_on(response.text()).unwrap(), "ok");
}

async fn spawn_http_assert_server<F>(assert_request: F) -> ugi::Result<String>
where
    F: FnOnce(String) + Send + 'static,
{
    let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|err| {
        ugi::Error::with_source(ugi::ErrorKind::Transport, "failed to bind test server", err)
    })?;
    let addr = listener.local_addr().map_err(|err| {
        ugi::Error::with_source(
            ugi::ErrorKind::Transport,
            "failed to inspect test server",
            err,
        )
    })?;

    thread::spawn(move || {
        block_on(async move {
            let (mut stream, _) = listener.accept().await.unwrap();
            let mut request = Vec::new();
            loop {
                let mut chunk = [0_u8; 1024];
                let read = stream.read(&mut chunk).await.unwrap();
                if read == 0 {
                    break;
                }
                request.extend_from_slice(&chunk[..read]);
                if request.windows(4).any(|window| window == b"\r\n\r\n") {
                    break;
                }
            }
            assert_request(String::from_utf8_lossy(&request).to_string());
            stream
                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
                .await
                .unwrap();
            stream.flush().await.unwrap();
        });
    });

    Ok(format!("http://{}", addr))
}