ugi 0.2.1

Runtime-agnostic Rust request client with HTTP/1.1, HTTP/2, HTTP/3, H2C, WebSocket, SSE, and gRPC support
Documentation
//! HTTP/3 integration tests — end-to-end tests using a real QUIC server.
//!
//! These tests require the `h3` feature to compile and run.
//!
//! The server is a minimal quiche h3 server running in a background thread.

#[cfg(not(feature = "h3"))]
mod disabled {
    #[test]
    #[ignore = "h3 integration tests require --features h3"]
    fn h3_integration_requires_h3_feature() {
        // Keep a visible reminder without failing CI/local `cargo test`.
    }
}

#[cfg(feature = "h3")]
mod enabled {
    use quiche::h3::NameValue;
    use std::net::{SocketAddr, UdpSocket};
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::thread;
    use std::time::Duration;
    use ugi::StatusCode;

    const MAX_DATAGRAM_SIZE: usize = 1350;

    fn spawn_quic_server(shutdown: Arc<AtomicBool>) -> SocketAddr {
        let socket = UdpSocket::bind(("127.0.0.1", 0)).unwrap();
        let addr = socket.local_addr().unwrap();

        let cert_pem = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
        let cert_pem_str = cert_pem.cert.pem();
        let key_pem_str = cert_pem.signing_key.serialize_pem();

        thread::spawn(move || {
            let tmp = tempfile::tempdir().unwrap();
            let cert_path = tmp.path().join("cert.pem");
            let key_path = tmp.path().join("key.pem");
            std::fs::write(&cert_path, &cert_pem_str).unwrap();
            std::fs::write(&key_path, &key_pem_str).unwrap();

            let mut config = quiche::Config::new(quiche::PROTOCOL_VERSION).unwrap();
            config
                .load_cert_chain_from_pem_file(cert_path.to_str().unwrap())
                .unwrap();
            config
                .load_priv_key_from_pem_file(key_path.to_str().unwrap())
                .unwrap();
            config
                .set_application_protos(quiche::h3::APPLICATION_PROTOCOL)
                .unwrap();
            config.set_max_idle_timeout(30_000);
            config.set_max_recv_udp_payload_size(MAX_DATAGRAM_SIZE);
            config.set_max_send_udp_payload_size(MAX_DATAGRAM_SIZE);
            config.set_initial_max_data(10_000_000);
            config.set_initial_max_stream_data_bidi_local(1_000_000);
            config.set_initial_max_stream_data_bidi_remote(1_000_000);
            config.set_initial_max_stream_data_uni(1_000_000);
            config.set_initial_max_streams_bidi(100);
            config.set_initial_max_streams_uni(100);
            config.set_disable_active_migration(true);
            config.enable_early_data();

            let h3_config = quiche::h3::Config::new().unwrap();

            let mut clients: std::collections::HashMap<
                quiche::ConnectionId<'static>,
                (quiche::Connection, Option<quiche::h3::Connection>),
            > = std::collections::HashMap::new();

            let mut dcid_map: std::collections::HashMap<
                quiche::ConnectionId<'static>,
                quiche::ConnectionId<'static>,
            > = std::collections::HashMap::new();

            let mut buf = [0u8; 65535];
            let mut out = [0u8; MAX_DATAGRAM_SIZE];

            socket
                .set_read_timeout(Some(Duration::from_millis(10)))
                .unwrap();

            while !shutdown.load(Ordering::Relaxed) {
                for (_, (conn, _)) in clients.iter_mut() {
                    conn.on_timeout();
                }

                loop {
                    let (read, from) = match socket.recv_from(&mut buf) {
                        Ok(v) => v,
                        Err(_) => break,
                    };

                    let hdr =
                        match quiche::Header::from_slice(&mut buf[..read], quiche::MAX_CONN_ID_LEN)
                        {
                            Ok(h) => h,
                            Err(_) => continue,
                        };

                    let scid_key = if let Some(sk) = dcid_map.get(&hdr.dcid) {
                        sk.clone()
                    } else if clients.contains_key(&hdr.dcid) {
                        hdr.dcid.clone().into_owned()
                    } else {
                        if hdr.ty != quiche::Type::Initial {
                            continue;
                        }
                        let mut scid_bytes = [0u8; quiche::MAX_CONN_ID_LEN];
                        use std::time::SystemTime;
                        let seed = SystemTime::now()
                            .duration_since(SystemTime::UNIX_EPOCH)
                            .unwrap()
                            .as_nanos() as u64;
                        scid_bytes[..8].copy_from_slice(&seed.to_le_bytes());
                        let scid = quiche::ConnectionId::from_vec(scid_bytes.to_vec());

                        let conn = quiche::accept(&scid, None, addr, from, &mut config).unwrap();

                        dcid_map.insert(hdr.dcid.clone().into_owned(), scid.clone());
                        clients.insert(scid.clone(), (conn, None));
                        scid
                    };

                    let (conn, h3_opt) = match clients.get_mut(&scid_key) {
                        Some(v) => v,
                        None => continue,
                    };

                    let info = quiche::RecvInfo { from, to: addr };
                    let _ = conn.recv(&mut buf[..read], info);

                    if conn.is_established() && h3_opt.is_none() {
                        if let Ok(h3) = quiche::h3::Connection::with_transport(conn, &h3_config) {
                            *h3_opt = Some(h3);
                        }
                    }

                    if let Some(h3) = h3_opt.as_mut() {
                        loop {
                            match h3.poll(conn) {
                                Ok((stream_id, quiche::h3::Event::Headers { list, .. })) => {
                                    let path = list
                                        .iter()
                                        .find(|h| h.name() == b":path")
                                        .map(|h| h.value().to_vec());

                                    let (status, body) = match path.as_deref() {
                                        Some(b"/ping") => (200, b"pong".to_vec()),
                                        Some(b"/json") => (200, br#"{"message":"hello"}"#.to_vec()),
                                        _ => (404, b"not found".to_vec()),
                                    };

                                    let status_bytes = status.to_string().into_bytes();
                                    let content_length = body.len().to_string().into_bytes();

                                    let response_headers = vec![
                                        quiche::h3::Header::new(b":status", &status_bytes),
                                        quiche::h3::Header::new(b"content-length", &content_length),
                                    ];
                                    let _ =
                                        h3.send_response(conn, stream_id, &response_headers, false);
                                    let _ = h3.send_body(conn, stream_id, &body, true);
                                }
                                Ok((_, quiche::h3::Event::Data)) => {}
                                Ok((_, quiche::h3::Event::Finished)) => {}
                                Ok((_, quiche::h3::Event::Reset(_))) => {}
                                Ok((_, quiche::h3::Event::GoAway)) => {}
                                Ok((_, quiche::h3::Event::PriorityUpdate)) => {}
                                Err(quiche::h3::Error::Done) => break,
                                Err(_) => break,
                            }
                        }
                    }

                    loop {
                        match conn.send(&mut out) {
                            Ok((written, info)) => {
                                let _ = socket.send_to(&out[..written], info.to);
                            }
                            Err(quiche::Error::Done) => break,
                            Err(_) => break,
                        }
                    }
                }

                let mut to_remove = vec![];
                for (scid, (conn, _)) in clients.iter_mut() {
                    if conn.is_closed() {
                        to_remove.push(scid.clone());
                    }
                }
                for id in to_remove {
                    clients.remove(&id);
                }
            }
        });

        addr
    }

    #[test]
    fn h3_basic_get_request() {
        let shutdown = Arc::new(AtomicBool::new(false));
        let server_addr = spawn_quic_server(Arc::clone(&shutdown));

        let base_url = format!("https://localhost:{}", server_addr.port());

        async_io::block_on(async move {
            let client = ugi::Client::builder()
                .http3_only()
                .timeout(Duration::from_secs(10))
                .build()
                .unwrap();

            let resp = client.get(&format!("{base_url}/ping")).await.unwrap();
            assert_eq!(resp.status(), StatusCode::OK);
            let body = resp.text().await.unwrap();
            assert_eq!(body, "pong");
        });

        shutdown.store(true, Ordering::Relaxed);
        thread::sleep(Duration::from_millis(50));
    }

    #[test]
    fn h3_json_response() {
        let shutdown = Arc::new(AtomicBool::new(false));
        let server_addr = spawn_quic_server(Arc::clone(&shutdown));

        let base_url = format!("https://localhost:{}", server_addr.port());

        async_io::block_on(async move {
            let client = ugi::Client::builder()
                .http3_only()
                .timeout(Duration::from_secs(10))
                .build()
                .unwrap();

            let resp = client.get(&format!("{base_url}/json")).await.unwrap();
            assert_eq!(resp.status(), StatusCode::OK);
            let body = resp.text().await.unwrap();
            assert_eq!(body, r#"{"message":"hello"}"#);
        });

        shutdown.store(true, Ordering::Relaxed);
        thread::sleep(Duration::from_millis(50));
    }
}