cloud-sdk-reqwest 0.16.0

Optional provider-neutral reqwest transport boundary for cloud-sdk.
Documentation
use std::string::String;
use std::time::Duration;

use cloud_sdk::Method;
use cloud_sdk::transport::{AsyncTransport, ContentType, RequestTarget, TransportRequest};

use super::{
    AsyncClient, AsyncClientBuilder, BearerToken, HttpsEndpoint, RequestTimeouts, TransportError,
    UserAgent,
};
use crate::test_server::{spawn, spawn_split};

fn run_async_test(future: impl core::future::Future<Output = ()>) {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_io()
        .enable_time()
        .build();
    assert!(runtime.is_ok());
    if let Ok(runtime) = runtime {
        runtime.block_on(future);
    }
}

fn test_timeouts() -> Option<RequestTimeouts> {
    RequestTimeouts::new(Duration::from_secs(2), Duration::from_secs(1)).ok()
}

fn build_loopback(endpoint: &str) -> Option<AsyncClient> {
    let endpoint = HttpsEndpoint::local_http(endpoint).ok()?;
    let token = BearerToken::new("test-token").ok()?;
    let user_agent = UserAgent::new("cloud-sdk-test/0.18").ok()?;
    let timeouts = test_timeouts()?;
    AsyncClientBuilder::new(endpoint, token, user_agent, timeouts)
        .build_for_loopback()
        .ok()
}

#[test]
fn async_client_sends_exact_headers_target_and_body_once() {
    run_async_test(async {
        let server = spawn(
            "503 Service Unavailable",
            &[],
            b"retry-later",
            Duration::ZERO,
        );
        let Ok(server) = server else { return };
        let Some(mut client) = build_loopback(&server.endpoint) else {
            return;
        };
        let Ok(target) = RequestTarget::new("/servers?name=test%20server") else {
            return;
        };
        let request = TransportRequest::new(Method::Post, target)
            .with_body(br#"{"name":"server"}"#)
            .with_content_type(ContentType::JSON);
        let mut output = [0xa5_u8; 32];
        let response = AsyncTransport::send(&mut client, request, &mut output).await;
        assert!(response.is_ok());
        if let Ok(response) = response {
            assert_eq!(response.status().get(), 503);
            assert_eq!(response.body(), b"retry-later");
        }

        let recorded = server.request.recv_timeout(Duration::from_secs(2));
        assert!(recorded.is_ok());
        if let Ok(recorded) = recorded {
            let wire = String::from_utf8_lossy(&recorded.bytes).to_ascii_lowercase();
            assert!(wire.starts_with("post /v1/servers?name=test%20server http/1.1\r\n"));
            assert!(wire.contains("authorization: bearer test-token\r\n"));
            assert!(wire.contains("user-agent: cloud-sdk-test/0.18\r\n"));
            assert!(wire.contains("content-type: application/json\r\n"));
            assert!(wire.ends_with(r#"{"name":"server"}"#));
        }
    });
}

#[test]
fn async_redirect_is_not_followed_and_oversized_body_is_rejected() {
    run_async_test(async {
        let redirect = spawn(
            "302 Found",
            &[("Location", "https://evil.example/steal")],
            b"redirect",
            Duration::ZERO,
        );
        let Ok(redirect) = redirect else { return };
        let Some(mut client) = build_loopback(&redirect.endpoint) else {
            return;
        };
        let Ok(target) = RequestTarget::new("/servers") else {
            return;
        };
        let mut output = [0_u8; 16];
        let response = AsyncTransport::send(
            &mut client,
            TransportRequest::new(Method::Get, target),
            &mut output,
        )
        .await;
        assert!(response.is_ok());
        if let Ok(response) = response {
            assert_eq!(response.status().get(), 302);
            assert_eq!(response.body(), b"redirect");
        }

        let oversized = spawn("200 OK", &[], b"oversized", Duration::ZERO);
        let Ok(oversized) = oversized else { return };
        let Some(mut client) = build_loopback(&oversized.endpoint) else {
            return;
        };
        let mut short = [0xa5_u8; 4];
        let result = AsyncTransport::send(
            &mut client,
            TransportRequest::new(Method::Get, target),
            &mut short,
        )
        .await;
        assert!(matches!(result, Err(TransportError::ResponseTooLarge)));
        assert_eq!(short, [0_u8; 4]);
    });
}

#[test]
fn async_response_propagates_validated_rate_limit_headers() {
    run_async_test(async {
        let server = spawn(
            "200 OK",
            &[
                ("RateLimit-Limit", "3600"),
                ("RateLimit-Remaining", "3599"),
                ("RateLimit-Reset", "42"),
            ],
            b"{}",
            Duration::ZERO,
        );
        let Ok(server) = server else { return };
        let Some(mut client) = build_loopback(&server.endpoint) else {
            return;
        };
        let Ok(target) = RequestTarget::new("/servers") else {
            return;
        };
        let mut output = [0_u8; 8];
        let response = AsyncTransport::send(
            &mut client,
            TransportRequest::new(Method::Get, target),
            &mut output,
        )
        .await;
        assert!(response.is_ok());
        let Some(rate_limit) = response.ok().and_then(|value| value.rate_limit()) else {
            return;
        };
        assert_eq!(rate_limit.limit(), 3600);
        assert_eq!(rate_limit.remaining(), 3599);
        assert_eq!(rate_limit.reset_epoch_seconds(), 42);
    });
}

#[test]
fn async_duplicate_rate_limit_headers_fail_closed() {
    run_async_test(async {
        let server = spawn(
            "200 OK",
            &[
                ("RateLimit-Limit", "3600"),
                ("RateLimit-Limit", "7200"),
                ("RateLimit-Remaining", "3599"),
                ("RateLimit-Reset", "42"),
            ],
            b"secret",
            Duration::ZERO,
        );
        let Ok(server) = server else { return };
        let Some(mut client) = build_loopback(&server.endpoint) else {
            return;
        };
        let Ok(target) = RequestTarget::new("/servers") else {
            return;
        };
        let mut output = [0xa5_u8; 8];
        let result = AsyncTransport::send(
            &mut client,
            TransportRequest::new(Method::Get, target),
            &mut output,
        )
        .await;
        assert!(matches!(
            result,
            Err(TransportError::InvalidRateLimitHeaders)
        ));
        assert_eq!(output, [0_u8; 8]);
    });
}

#[test]
fn missing_content_type_fails_before_network_access() {
    run_async_test(async {
        let Some(mut client) = build_loopback("http://127.0.0.1:9/v1") else {
            return;
        };
        let Ok(target) = RequestTarget::new("/servers") else {
            return;
        };
        let mut output = [0xa5_u8; 8];
        let result = AsyncTransport::send(
            &mut client,
            TransportRequest::new(Method::Post, target).with_body(b"{}"),
            &mut output,
        )
        .await;
        assert!(matches!(result, Err(TransportError::MissingContentType)));
        assert_eq!(output, [0_u8; 8]);
    });
}

#[test]
fn internal_timeout_is_payload_free_and_clears_output() {
    run_async_test(async {
        let server = spawn("200 OK", &[], b"late", Duration::from_millis(100));
        let Ok(server) = server else { return };
        let endpoint = HttpsEndpoint::local_http(&server.endpoint);
        let token = BearerToken::new("test-token");
        let user_agent = UserAgent::new("cloud-sdk-test/0.18");
        let timeouts = RequestTimeouts::new(Duration::from_millis(40), Duration::from_millis(20));
        let (Ok(endpoint), Ok(token), Ok(user_agent), Ok(timeouts)) =
            (endpoint, token, user_agent, timeouts)
        else {
            return;
        };
        let client =
            AsyncClientBuilder::new(endpoint, token, user_agent, timeouts).build_for_loopback();
        let Ok(mut client) = client else { return };
        let Ok(target) = RequestTarget::new("/slow") else {
            return;
        };
        let mut output = [0xa5_u8; 8];
        let result = AsyncTransport::send(
            &mut client,
            TransportRequest::new(Method::Get, target),
            &mut output,
        )
        .await;
        assert!(matches!(result, Err(TransportError::TimedOut)));
        assert_eq!(output, [0_u8; 8]);
    });
}

#[test]
fn caller_cancellation_after_partial_body_never_exposes_response() {
    run_async_test(async {
        let server = spawn_split(
            "200 OK",
            b"secret-prefix",
            b"-tail",
            Duration::from_millis(500),
        );
        let Ok(server) = server else { return };
        let Some(mut client) = build_loopback(&server.endpoint) else {
            return;
        };
        let Ok(target) = RequestTarget::new("/slow") else {
            return;
        };
        let mut output = [0xa5_u8; 32];
        let future = AsyncTransport::send(
            &mut client,
            TransportRequest::new(Method::Get, target),
            &mut output,
        );
        let result = tokio::time::timeout(Duration::from_millis(100), future).await;
        assert!(result.is_err(), "unexpected early completion: {result:?}");
        assert_eq!(output, [0_u8; 32]);
    });
}

#[test]
fn async_client_debug_redacts_endpoint_and_token() {
    let endpoint = HttpsEndpoint::new("https://api.example.test/v1");
    let token = BearerToken::new("secret-token");
    let user_agent = UserAgent::new("cloud-sdk-test/0.18");
    let timeouts = test_timeouts();
    let (Ok(endpoint), Ok(token), Ok(user_agent), Some(timeouts)) =
        (endpoint, token, user_agent, timeouts)
    else {
        return;
    };
    let builder = AsyncClientBuilder::new(endpoint, token, user_agent, timeouts);
    let debug = std::format!("{builder:?}");
    assert!(debug.contains("[redacted]"));
    assert!(!debug.contains("secret-token"));
    assert!(!debug.contains("api.example.test"));
}