diff --git a/packages/python/src/lib.rs b/packages/python/src/lib.rs index 548158b..b63301a 100644 --- a/packages/python/src/lib.rs +++ b/packages/python/src/lib.rs @@ -158,12 +158,17 @@ impl CatsuClient { /// api_keys: Optional dict of provider names to API keys /// max_retries: Maximum number of retry attempts (default: 3) /// timeout: Request timeout in seconds (default: 30) + /// proxy: Optional HTTP/HTTPS proxy URL (e.g. "http://proxy.corp.com:8080"). + /// When None (default), reqwest respects HTTP_PROXY/HTTPS_PROXY env vars. + /// ca_cert: Optional PEM-encoded CA certificate string for custom certificate authorities #[new] - #[pyo3(signature = (api_keys=None, max_retries=None, timeout=None))] + #[pyo3(signature = (api_keys=None, max_retries=None, timeout=None, proxy=None, ca_cert=None))] pub fn new( api_keys: Option>, max_retries: Option, timeout: Option, + proxy: Option, + ca_cert: Option, ) -> PyResult { let mut config = HttpConfig::default(); if let Some(retries) = max_retries { @@ -172,6 +177,8 @@ impl CatsuClient { if let Some(secs) = timeout { config.timeout_secs = secs; } + config.proxy = proxy.filter(|s| !s.is_empty()); + config.ca_cert_pem = ca_cert.filter(|s| !s.is_empty()); let inner = if let Some(keys) = api_keys { RustClient::with_api_keys_and_config(keys, config) diff --git a/packages/python/tests/test_client.py b/packages/python/tests/test_client.py new file mode 100644 index 0000000..8c130b7 --- /dev/null +++ b/packages/python/tests/test_client.py @@ -0,0 +1,64 @@ +import pytest + + +def test_client_with_proxy(): + """Test that Client accepts a proxy parameter.""" + from catsu import Client + + client = Client(proxy="http://proxy.example.com:8080") + assert client is not None + + +def test_client_with_invalid_ca_cert(): + """Test that Client raises an error for invalid CA certificate.""" + from catsu import Client + + with pytest.raises(RuntimeError): + Client(ca_cert="not a valid certificate") + + +def test_client_with_invalid_proxy(): + """Test that Client raises an error for invalid proxy URL.""" + from catsu import Client + + with pytest.raises(RuntimeError): + Client(proxy="not-a-valid-url") + + +def test_client_with_valid_ca_cert_format(): + """Test that Client accepts a valid PEM format CA certificate. + + Note: This uses a syntactically valid but fake certificate. + The client may fail later when making requests, but construction should work. + """ + from catsu import Client + + # A minimal self-signed test certificate (valid PEM format) + test_cert = """-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu +dXNlZDAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC6fGQKtQ3u3tLGDNnM8Jv2 +vHNJJnKJkf8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6j +AgMBAAEwDQYJKoZIhvcNAQELBQADQQBN6V7t8Hy8cWJxmXNvh8J6jJJ8J8J6jJJ8 +J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6 +-----END CERTIFICATE-----""" + + # This may raise due to invalid cert content, but should at least parse the PEM format + try: + client = Client(ca_cert=test_cert) + assert client is not None + except RuntimeError as e: + # Accept errors about invalid cert content, but not about unexpected params + assert "certificate" in str(e).lower() or "pem" in str(e).lower() or "base64" in str(e).lower() + + +def test_client_with_proxy_and_other_options(): + """Test that proxy can be combined with other options.""" + from catsu import Client + + client = Client( + proxy="http://proxy.example.com:8080", + max_retries=5, + timeout=60, + ) + assert client is not None diff --git a/src/http.rs b/src/http.rs index 9f6c7e2..037a6ef 100644 --- a/src/http.rs +++ b/src/http.rs @@ -19,6 +19,10 @@ pub struct HttpConfig { pub max_backoff_ms: u64, /// Request timeout in seconds. pub timeout_secs: u64, + /// HTTP/HTTPS proxy URL. + pub proxy: Option, + /// PEM-encoded CA certificate. + pub ca_cert_pem: Option, } impl Default for HttpConfig { @@ -28,6 +32,8 @@ impl Default for HttpConfig { initial_backoff_ms: 100, max_backoff_ms: 30_000, timeout_secs: 30, + proxy: None, + ca_cert_pem: None, } } } @@ -42,10 +48,21 @@ pub struct HttpClient { impl HttpClient { /// Create a new HTTP client with the given configuration. pub fn new(config: HttpConfig) -> Result { - let client = Client::builder() + let mut builder = Client::builder() .timeout(Duration::from_secs(config.timeout_secs)) - .pool_max_idle_per_host(10) - .build()?; + .pool_max_idle_per_host(10); + + if let Some(ref proxy_url) = config.proxy { + let proxy = reqwest::Proxy::all(proxy_url)?; + builder = builder.proxy(proxy); + } + + if let Some(ref pem) = config.ca_cert_pem { + let cert = reqwest::Certificate::from_pem(pem.as_bytes())?; + builder = builder.add_root_certificate(cert); + } + + let client = builder.build()?; Ok(Self { client, config }) } @@ -175,4 +192,36 @@ mod tests { assert!(!is_retryable_status(401)); assert!(!is_retryable_status(404)); } + + #[test] + fn test_config_with_proxy_and_ca_cert() { + let config = HttpConfig { + proxy: Some("http://proxy.example.com:8080".to_string()), + ca_cert_pem: Some( + "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----".to_string(), + ), + ..HttpConfig::default() + }; + assert_eq!( + config.proxy, + Some("http://proxy.example.com:8080".to_string()) + ); + assert!(config.ca_cert_pem.is_some()); + assert!(config.ca_cert_pem.unwrap().contains("BEGIN CERTIFICATE")); + } + + #[test] + fn test_default_config_has_no_proxy_or_ca_cert() { + let config = HttpConfig::default(); + assert!(config.proxy.is_none()); + assert!(config.ca_cert_pem.is_none()); + } + + #[test] + fn test_http_client_new_with_proxy() { + let mut config = HttpConfig::default(); + config.proxy = Some("http://proxy.example.com:8080".to_string()); + let result = HttpClient::new(config); + assert!(result.is_ok()); + } }