runpod_sdk/client/
config.rs

1//! RunPod client configuration and builder.
2//!
3//! This module provides the configuration types and builder pattern for creating
4//! and customizing [`RunpodClient`] instances.
5
6use std::fmt;
7use std::time::Duration;
8
9use derive_builder::Builder;
10use reqwest::Client;
11
12use crate::Result;
13#[cfg(feature = "tracing")]
14use crate::TRACING_TARGET_CONFIG;
15use crate::client::RunpodClient;
16
17/// Configuration for the Runpod API client.
18///
19/// This struct holds all the necessary configuration parameters for creating and using
20/// a Runpod API client, including authentication credentials, API endpoint information,
21/// and HTTP client settings.
22///
23/// # Examples
24///
25/// Creating a config with defaults:
26/// ```no_run
27/// # use runpod_sdk::RunpodConfig;
28/// let config = RunpodConfig::builder()
29///     .with_api_key("your-api-key")
30///     .build()
31///     .unwrap();
32/// ```
33///
34/// Creating a config from environment:
35/// ```no_run
36/// # use runpod_sdk::RunpodConfig;
37/// // Requires RUNPOD_API_KEY environment variable
38/// let config = RunpodConfig::from_env().unwrap();
39/// ```
40///
41/// Custom configuration:
42/// ```no_run
43/// # use runpod_sdk::RunpodConfig;
44/// # use std::time::Duration;
45/// let config = RunpodConfig::builder()
46///     .with_api_key("your-api-key")
47///     .with_rest_url("https://custom.api.com")
48///     .with_timeout(Duration::from_secs(60))
49///     .build()
50///     .unwrap();
51/// ```
52#[derive(Clone, Builder)]
53#[builder(
54    name = "RunpodBuilder",
55    pattern = "owned",
56    setter(into, strip_option, prefix = "with"),
57    build_fn(validate = "Self::validate_config")
58)]
59pub struct RunpodConfig {
60    /// API key for authentication with the Runpod API.
61    ///
62    /// You can obtain your API key from the Runpod dashboard.
63    api_key: String,
64
65    /// Base REST URL for the Runpod API.
66    ///
67    /// Defaults to the official Runpod REST API endpoint.
68    #[builder(default = "Self::default_rest_url()")]
69    rest_url: String,
70
71    /// Base API URL for the Runpod serverless endpoints.
72    ///
73    /// Defaults to the official Runpod API endpoint.
74    #[cfg(feature = "serverless")]
75    #[cfg_attr(docsrs, doc(cfg(feature = "serverless")))]
76    #[builder(default = "Self::default_api_url()")]
77    api_url: String,
78
79    /// Timeout for HTTP requests.
80    ///
81    /// Controls how long the client will wait for API responses before timing out.
82    #[builder(default = "Self::default_timeout()")]
83    timeout: Duration,
84
85    /// Optional custom reqwest client.
86    ///
87    /// If provided, this client will be used instead of creating a new one.
88    /// This allows for custom configuration of the HTTP client (e.g., proxies, custom headers, etc.).
89    #[builder(default = "None")]
90    client: Option<Client>,
91}
92
93impl RunpodBuilder {
94    /// Returns the default REST URL for the Runpod API.
95    fn default_rest_url() -> String {
96        "https://rest.runpod.io/v1".to_string()
97    }
98
99    /// Returns the default API URL for the Runpod serverless endpoints.
100    #[cfg(feature = "serverless")]
101    #[cfg_attr(docsrs, doc(cfg(feature = "serverless")))]
102    fn default_api_url() -> String {
103        "https://api.runpod.io/v2".to_string()
104    }
105
106    /// Returns the default timeout.
107    fn default_timeout() -> Duration {
108        Duration::from_secs(30)
109    }
110
111    /// Validates the configuration before building.
112    fn validate_config(&self) -> Result<(), String> {
113        // Validate API key is not empty
114        if let Some(ref api_key) = self.api_key
115            && api_key.trim().is_empty()
116        {
117            return Err("API key cannot be empty".to_string());
118        }
119
120        // Validate timeout is reasonable
121        if let Some(timeout) = self.timeout {
122            if timeout.is_zero() {
123                return Err("Timeout must be greater than 0".to_string());
124            }
125            if timeout > Duration::from_secs(300) {
126                return Err("Timeout cannot exceed 300 seconds (5 minutes)".to_string());
127            }
128        }
129
130        Ok(())
131    }
132
133    /// Creates a RunPod API client directly from the builder.
134    ///
135    /// This is a convenience method that builds the configuration and
136    /// creates a client in one step. This is the recommended way to
137    /// create a client.
138    ///
139    /// # Examples
140    ///
141    /// ```no_run
142    /// # use runpod_sdk::RunpodConfig;
143    /// let client = RunpodConfig::builder()
144    ///     .with_api_key("your-api-key")
145    ///     .build_client()
146    ///     .unwrap();
147    /// ```
148    pub fn build_client(self) -> Result<RunpodClient> {
149        let config = self.build()?;
150        RunpodClient::new(config)
151    }
152}
153
154impl RunpodConfig {
155    /// Creates a new configuration builder.
156    ///
157    /// This is the recommended way to construct a `RunpodConfig`.
158    ///
159    /// # Examples
160    ///
161    /// ```no_run
162    /// # use runpod_sdk::RunpodConfig;
163    /// let config = RunpodConfig::builder()
164    ///     .with_api_key("your-api-key")
165    ///     .build()
166    ///     .unwrap();
167    /// ```
168    pub fn builder() -> RunpodBuilder {
169        RunpodBuilder::default()
170    }
171
172    /// Creates a new RunPod API client using this configuration.
173    ///
174    /// # Examples
175    ///
176    /// ```no_run
177    /// # use runpod_sdk::RunpodConfig;
178    /// let config = RunpodConfig::builder()
179    ///     .with_api_key("your-api-key")
180    ///     .build()
181    ///     .unwrap();
182    ///
183    /// let client = config.build_client().unwrap();
184    /// ```
185    pub fn build_client(self) -> Result<RunpodClient> {
186        RunpodClient::new(self)
187    }
188
189    /// Returns the API key.
190    pub fn api_key(&self) -> &str {
191        &self.api_key
192    }
193
194    /// Returns a masked version of the API key for safe display/logging.
195    ///
196    /// Shows the first 4 characters followed by "****", or just "****"
197    /// if the key is shorter than 4 characters.
198    pub fn masked_api_key(&self) -> String {
199        if self.api_key.len() > 4 {
200            format!("{}****", &self.api_key[..4])
201        } else {
202            "****".to_string()
203        }
204    }
205
206    /// Returns the base REST URL.
207    pub fn rest_url(&self) -> &str {
208        &self.rest_url
209    }
210
211    /// Returns the API URL for serverless endpoints.
212    #[cfg(feature = "serverless")]
213    #[cfg_attr(docsrs, doc(cfg(feature = "serverless")))]
214    pub fn api_url(&self) -> &str {
215        &self.api_url
216    }
217
218    /// Returns the timeout duration.
219    pub fn timeout(&self) -> Duration {
220        self.timeout
221    }
222
223    /// Returns a clone of the custom reqwest client, if one was provided.
224    pub(crate) fn client(&self) -> Option<Client> {
225        self.client.clone()
226    }
227
228    /// Creates a configuration from environment variables.
229    ///
230    /// Reads the API key from the `RUNPOD_API_KEY` environment variable.
231    /// Optionally reads `RUNPOD_REST_URL`, `RUNPOD_API_URL` (with serverless feature),
232    /// and `RUNPOD_TIMEOUT_SECS` if set.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if:
237    /// - The `RUNPOD_API_KEY` environment variable is not set
238    /// - Any environment variable contains an invalid value
239    ///
240    /// # Examples
241    ///
242    /// ```no_run
243    /// # use runpod_sdk::RunpodConfig;
244    /// // Set environment variable first:
245    /// // export RUNPOD_API_KEY=your-api-key
246    /// let config = RunpodConfig::from_env().unwrap();
247    /// ```
248    #[cfg_attr(feature = "tracing", tracing::instrument)]
249    pub fn from_env() -> Result<Self> {
250        #[cfg(feature = "tracing")]
251        tracing::debug!(target: TRACING_TARGET_CONFIG, "Loading configuration from environment");
252
253        let api_key = std::env::var("RUNPOD_API_KEY").map_err(|_| {
254            #[cfg(feature = "tracing")]
255            tracing::error!(target: TRACING_TARGET_CONFIG, "RUNPOD_API_KEY environment variable not set");
256
257            RunpodBuilderError::ValidationError(
258                "RUNPOD_API_KEY environment variable not set".to_string(),
259            )
260        })?;
261
262        let mut builder = Self::builder().with_api_key(api_key);
263
264        // Optional: custom REST URL
265        if let Ok(rest_url) = std::env::var("RUNPOD_REST_URL") {
266            #[cfg(feature = "tracing")]
267            tracing::debug!(target: TRACING_TARGET_CONFIG, rest_url = %rest_url, "Using custom REST URL");
268
269            builder = builder.with_rest_url(rest_url);
270        }
271
272        // Optional: custom API URL for serverless endpoints
273        #[cfg(feature = "serverless")]
274        if let Ok(api_url) = std::env::var("RUNPOD_API_URL") {
275            #[cfg(feature = "tracing")]
276            tracing::debug!(
277                target: TRACING_TARGET_CONFIG,
278                api_url = %api_url,
279                "Using custom API URL"
280            );
281
282            builder = builder.with_api_url(api_url);
283        }
284
285        // Optional: custom timeout
286        if let Ok(timeout_str) = std::env::var("RUNPOD_TIMEOUT_SECS") {
287            let timeout_secs = timeout_str.parse::<u64>().map_err(|_| {
288                #[cfg(feature = "tracing")]
289                tracing::error!(target: TRACING_TARGET_CONFIG, timeout_str = %timeout_str, "Invalid RUNPOD_TIMEOUT_SECS value");
290
291                RunpodBuilderError::ValidationError(format!(
292                    "Invalid RUNPOD_TIMEOUT_SECS value: {}",
293                    timeout_str
294                ))
295            })?;
296
297            #[cfg(feature = "tracing")]
298            tracing::debug!(target: TRACING_TARGET_CONFIG, timeout_secs, "Using custom timeout");
299
300            builder = builder.with_timeout(Duration::from_secs(timeout_secs));
301        }
302
303        let config = builder.build()?;
304
305        #[cfg(feature = "tracing")]
306        tracing::info!(target: TRACING_TARGET_CONFIG,
307            rest_url = %config.rest_url(),
308            timeout = ?config.timeout(),
309            "Configuration loaded successfully from environment"
310        );
311
312        Ok(config)
313    }
314}
315
316impl fmt::Debug for RunpodConfig {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        let mut debug_struct = f.debug_struct("RunpodConfig");
319        debug_struct
320            .field("api_key", &self.masked_api_key())
321            .field("rest_url", &self.rest_url)
322            .field("timeout", &self.timeout);
323
324        #[cfg(feature = "serverless")]
325        debug_struct.field("api_url", &self.api_url);
326        debug_struct.finish()
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_config_builder() -> Result<()> {
336        let config = RunpodConfig::builder().with_api_key("test_key").build()?;
337
338        assert_eq!(config.api_key(), "test_key");
339        assert_eq!(config.rest_url(), "https://rest.runpod.io/v1");
340        #[cfg(feature = "serverless")]
341        assert_eq!(config.api_url(), "https://api.runpod.io/v2");
342        assert_eq!(config.timeout(), Duration::from_secs(30));
343
344        Ok(())
345    }
346
347    #[test]
348    fn test_config_builder_with_custom_values() -> Result<()> {
349        let config = RunpodConfig::builder()
350            .with_api_key("test_key")
351            .with_rest_url("https://custom.api.com")
352            .with_timeout(Duration::from_secs(60))
353            .build()?;
354
355        assert_eq!(config.api_key(), "test_key");
356        assert_eq!(config.rest_url(), "https://custom.api.com");
357        assert_eq!(config.timeout(), Duration::from_secs(60));
358
359        Ok(())
360    }
361
362    #[test]
363    fn test_config_validation_empty_api_key() {
364        let result = RunpodConfig::builder().with_api_key("").build();
365        assert!(result.is_err());
366    }
367
368    #[test]
369    fn test_config_validation_zero_timeout() {
370        let result = RunpodConfig::builder()
371            .with_api_key("test_key")
372            .with_timeout(Duration::from_secs(0))
373            .build();
374
375        assert!(result.is_err());
376    }
377
378    #[test]
379    fn test_config_validation_excessive_timeout() {
380        let result = RunpodConfig::builder()
381            .with_api_key("test_key")
382            .with_timeout(Duration::from_secs(400))
383            .build();
384
385        assert!(result.is_err());
386    }
387
388    #[test]
389    fn test_config_builder_with_all_options() -> Result<()> {
390        let config = RunpodConfig::builder()
391            .with_api_key("test_key_comprehensive")
392            .with_rest_url("https://api.custom-domain.com/v2")
393            .with_timeout(Duration::from_secs(120))
394            .build()?;
395
396        assert_eq!(config.api_key(), "test_key_comprehensive");
397        assert_eq!(config.rest_url(), "https://api.custom-domain.com/v2");
398        assert_eq!(config.timeout(), Duration::from_secs(120));
399
400        Ok(())
401    }
402
403    #[test]
404    fn test_config_builder_defaults() -> Result<()> {
405        let config = RunpodConfig::builder().with_api_key("test_key").build()?;
406
407        assert_eq!(config.api_key(), "test_key");
408        assert_eq!(config.rest_url(), "https://rest.runpod.io/v1");
409        assert_eq!(config.timeout(), Duration::from_secs(30));
410
411        Ok(())
412    }
413}