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    /// Base GraphQL URL for the Runpod API.
80    ///
81    /// Defaults to the official Runpod GraphQL API endpoint.
82    #[cfg(feature = "graphql")]
83    #[cfg_attr(docsrs, doc(cfg(feature = "graphql")))]
84    #[builder(default = "Self::default_graphql_url()")]
85    graphql_url: String,
86
87    /// Timeout for HTTP requests.
88    ///
89    /// Controls how long the client will wait for API responses before timing out.
90    #[builder(default = "Self::default_timeout()")]
91    timeout: Duration,
92
93    /// Optional custom reqwest client.
94    ///
95    /// If provided, this client will be used instead of creating a new one.
96    /// This allows for custom configuration of the HTTP client (e.g., proxies, custom headers, etc.).
97    #[builder(default = "None")]
98    client: Option<Client>,
99}
100
101impl RunpodBuilder {
102    /// Returns the default REST URL for the Runpod API.
103    fn default_rest_url() -> String {
104        "https://rest.runpod.io/v1".to_string()
105    }
106
107    /// Returns the default API URL for the Runpod serverless endpoints.
108    #[cfg(feature = "serverless")]
109    #[cfg_attr(docsrs, doc(cfg(feature = "serverless")))]
110    fn default_api_url() -> String {
111        "https://api.runpod.io/v2".to_string()
112    }
113
114    /// Returns the default base GraphQL URL for the Runpod API.
115    #[cfg(feature = "graphql")]
116    #[cfg_attr(docsrs, doc(cfg(feature = "graphql")))]
117    fn default_graphql_url() -> String {
118        "https://api.runpod.io/graphql".to_string()
119    }
120
121    /// Returns the default timeout.
122    fn default_timeout() -> Duration {
123        Duration::from_secs(30)
124    }
125
126    /// Validates the configuration before building.
127    fn validate_config(&self) -> Result<(), String> {
128        // Validate API key is not empty
129        if let Some(ref api_key) = self.api_key
130            && api_key.trim().is_empty()
131        {
132            return Err("API key cannot be empty".to_string());
133        }
134
135        // Validate timeout is reasonable
136        if let Some(timeout) = self.timeout {
137            if timeout.is_zero() {
138                return Err("Timeout must be greater than 0".to_string());
139            }
140            if timeout > Duration::from_secs(300) {
141                return Err("Timeout cannot exceed 300 seconds (5 minutes)".to_string());
142            }
143        }
144
145        Ok(())
146    }
147
148    /// Creates a RunPod API client directly from the builder.
149    ///
150    /// This is a convenience method that builds the configuration and
151    /// creates a client in one step. This is the recommended way to
152    /// create a client.
153    ///
154    /// # Examples
155    ///
156    /// ```no_run
157    /// # use runpod_sdk::RunpodConfig;
158    /// let client = RunpodConfig::builder()
159    ///     .with_api_key("your-api-key")
160    ///     .build_client()
161    ///     .unwrap();
162    /// ```
163    pub fn build_client(self) -> Result<RunpodClient> {
164        let config = self.build()?;
165        RunpodClient::new(config)
166    }
167}
168
169impl RunpodConfig {
170    /// Creates a new configuration builder.
171    ///
172    /// This is the recommended way to construct a `RunpodConfig`.
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    pub fn builder() -> RunpodBuilder {
184        RunpodBuilder::default()
185    }
186
187    /// Creates a new RunPod API client using this configuration.
188    ///
189    /// # Examples
190    ///
191    /// ```no_run
192    /// # use runpod_sdk::RunpodConfig;
193    /// let config = RunpodConfig::builder()
194    ///     .with_api_key("your-api-key")
195    ///     .build()
196    ///     .unwrap();
197    ///
198    /// let client = config.build_client().unwrap();
199    /// ```
200    pub fn build_client(self) -> Result<RunpodClient> {
201        RunpodClient::new(self)
202    }
203
204    /// Returns the API key.
205    pub fn api_key(&self) -> &str {
206        &self.api_key
207    }
208
209    /// Returns a masked version of the API key for safe display/logging.
210    ///
211    /// Shows the first 4 characters followed by "****", or just "****"
212    /// if the key is shorter than 4 characters.
213    pub fn masked_api_key(&self) -> String {
214        if self.api_key.len() > 4 {
215            format!("{}****", &self.api_key[..4])
216        } else {
217            "****".to_string()
218        }
219    }
220
221    /// Returns the base REST URL.
222    pub fn rest_url(&self) -> &str {
223        &self.rest_url
224    }
225
226    /// Returns the API URL for serverless endpoints.
227    #[cfg(feature = "serverless")]
228    #[cfg_attr(docsrs, doc(cfg(feature = "serverless")))]
229    pub fn api_url(&self) -> &str {
230        &self.api_url
231    }
232
233    /// Returns the base GraphQL URL.
234    #[cfg(feature = "graphql")]
235    #[cfg_attr(docsrs, doc(cfg(feature = "graphql")))]
236    pub fn graphql_url(&self) -> &str {
237        &self.graphql_url
238    }
239
240    /// Returns the timeout duration.
241    pub fn timeout(&self) -> Duration {
242        self.timeout
243    }
244
245    /// Returns a clone of the custom reqwest client, if one was provided.
246    pub(crate) fn client(&self) -> Option<Client> {
247        self.client.clone()
248    }
249
250    /// Creates a configuration from environment variables.
251    ///
252    /// Reads the API key from the `RUNPOD_API_KEY` environment variable.
253    /// Optionally reads `RUNPOD_REST_URL`, `RUNPOD_API_URL` (with serverless feature),
254    /// `RUNPOD_GRAPHQL_URL` (with graphql feature), and `RUNPOD_TIMEOUT_SECS` if set.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if:
259    /// - The `RUNPOD_API_KEY` environment variable is not set
260    /// - Any environment variable contains an invalid value
261    ///
262    /// # Examples
263    ///
264    /// ```no_run
265    /// # use runpod_sdk::RunpodConfig;
266    /// // Set environment variable first:
267    /// // export RUNPOD_API_KEY=your-api-key
268    /// let config = RunpodConfig::from_env().unwrap();
269    /// ```
270    #[cfg_attr(feature = "tracing", tracing::instrument)]
271    pub fn from_env() -> Result<Self> {
272        #[cfg(feature = "tracing")]
273        tracing::debug!(target: TRACING_TARGET_CONFIG, "Loading configuration from environment");
274
275        let api_key = std::env::var("RUNPOD_API_KEY").map_err(|_| {
276            #[cfg(feature = "tracing")]
277            tracing::error!(target: TRACING_TARGET_CONFIG, "RUNPOD_API_KEY environment variable not set");
278
279            RunpodBuilderError::ValidationError(
280                "RUNPOD_API_KEY environment variable not set".to_string(),
281            )
282        })?;
283
284        let mut builder = Self::builder().with_api_key(api_key);
285
286        // Optional: custom REST URL (also support legacy RUNPOD_BASE_URL)
287        if let Ok(rest_url) = std::env::var("RUNPOD_REST_URL") {
288            #[cfg(feature = "tracing")]
289            tracing::debug!(target: TRACING_TARGET_CONFIG, rest_url = %rest_url, "Using custom REST URL");
290
291            builder = builder.with_rest_url(rest_url);
292        } else if let Ok(base_url) = std::env::var("RUNPOD_BASE_URL") {
293            #[cfg(feature = "tracing")]
294            tracing::debug!(target: TRACING_TARGET_CONFIG, base_url = %base_url, "Using custom base URL (legacy)");
295
296            builder = builder.with_rest_url(base_url);
297        }
298
299        // Optional: custom API URL for serverless endpoints
300        #[cfg(feature = "serverless")]
301        if let Ok(api_url) = std::env::var("RUNPOD_API_URL") {
302            #[cfg(feature = "tracing")]
303            tracing::debug!(
304                target: TRACING_TARGET_CONFIG,
305                api_url = %api_url,
306                "Using custom API URL"
307            );
308
309            builder = builder.with_api_url(api_url);
310        }
311
312        // Optional: custom GraphQL URL
313        #[cfg(feature = "graphql")]
314        if let Ok(graphql_url) = std::env::var("RUNPOD_GRAPHQL_URL") {
315            #[cfg(feature = "tracing")]
316            tracing::debug!(
317                target: TRACING_TARGET_CONFIG,
318                graphql_url = %graphql_url,
319                "Using custom GraphQL URL"
320            );
321
322            builder = builder.with_graphql_url(graphql_url);
323        }
324
325        // Optional: custom timeout
326        if let Ok(timeout_str) = std::env::var("RUNPOD_TIMEOUT_SECS") {
327            let timeout_secs = timeout_str.parse::<u64>().map_err(|_| {
328                #[cfg(feature = "tracing")]
329                tracing::error!(target: TRACING_TARGET_CONFIG, timeout_str = %timeout_str, "Invalid RUNPOD_TIMEOUT_SECS value");
330
331                RunpodBuilderError::ValidationError(format!(
332                    "Invalid RUNPOD_TIMEOUT_SECS value: {}",
333                    timeout_str
334                ))
335            })?;
336
337            #[cfg(feature = "tracing")]
338            tracing::debug!(target: TRACING_TARGET_CONFIG, timeout_secs, "Using custom timeout");
339
340            builder = builder.with_timeout(Duration::from_secs(timeout_secs));
341        }
342
343        let config = builder.build()?;
344
345        #[cfg(feature = "tracing")]
346        tracing::info!(target: TRACING_TARGET_CONFIG,
347            rest_url = %config.rest_url(),
348            timeout = ?config.timeout(),
349            "Configuration loaded successfully from environment"
350        );
351
352        Ok(config)
353    }
354}
355
356impl fmt::Debug for RunpodConfig {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        let mut debug_struct = f.debug_struct("RunpodConfig");
359        debug_struct
360            .field("api_key", &self.masked_api_key())
361            .field("rest_url", &self.rest_url)
362            .field("timeout", &self.timeout);
363
364        #[cfg(feature = "serverless")]
365        debug_struct.field("api_url", &self.api_url);
366
367        #[cfg(feature = "graphql")]
368        debug_struct.field("graphql_url", &self.graphql_url);
369
370        debug_struct.finish()
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_config_builder() -> Result<()> {
380        let config = RunpodConfig::builder().with_api_key("test_key").build()?;
381
382        assert_eq!(config.api_key(), "test_key");
383        assert_eq!(config.rest_url(), "https://rest.runpod.io/v1");
384        #[cfg(feature = "serverless")]
385        assert_eq!(config.api_url(), "https://api.runpod.io/v2");
386        #[cfg(feature = "graphql")]
387        assert_eq!(config.graphql_url(), "https://api.runpod.io/graphql");
388        assert_eq!(config.timeout(), Duration::from_secs(30));
389
390        Ok(())
391    }
392
393    #[test]
394    fn test_config_builder_with_custom_values() -> Result<()> {
395        let config = RunpodConfig::builder()
396            .with_api_key("test_key")
397            .with_rest_url("https://custom.api.com")
398            .with_timeout(Duration::from_secs(60))
399            .build()?;
400
401        assert_eq!(config.api_key(), "test_key");
402        assert_eq!(config.rest_url(), "https://custom.api.com");
403        assert_eq!(config.timeout(), Duration::from_secs(60));
404
405        Ok(())
406    }
407
408    #[test]
409    fn test_config_validation_empty_api_key() {
410        let result = RunpodConfig::builder().with_api_key("").build();
411        assert!(result.is_err());
412    }
413
414    #[test]
415    fn test_config_validation_zero_timeout() {
416        let result = RunpodConfig::builder()
417            .with_api_key("test_key")
418            .with_timeout(Duration::from_secs(0))
419            .build();
420
421        assert!(result.is_err());
422    }
423
424    #[test]
425    fn test_config_validation_excessive_timeout() {
426        let result = RunpodConfig::builder()
427            .with_api_key("test_key")
428            .with_timeout(Duration::from_secs(400))
429            .build();
430
431        assert!(result.is_err());
432    }
433
434    #[test]
435    fn test_config_builder_with_all_options() -> Result<()> {
436        let config = RunpodConfig::builder()
437            .with_api_key("test_key_comprehensive")
438            .with_rest_url("https://api.custom-domain.com/v2")
439            .with_timeout(Duration::from_secs(120))
440            .build()?;
441
442        assert_eq!(config.api_key(), "test_key_comprehensive");
443        assert_eq!(config.rest_url(), "https://api.custom-domain.com/v2");
444        assert_eq!(config.timeout(), Duration::from_secs(120));
445
446        Ok(())
447    }
448
449    #[test]
450    fn test_config_builder_defaults() -> Result<()> {
451        let config = RunpodConfig::builder().with_api_key("test_key").build()?;
452
453        assert_eq!(config.api_key(), "test_key");
454        assert_eq!(config.rest_url(), "https://rest.runpod.io/v1");
455        assert_eq!(config.timeout(), Duration::from_secs(30));
456
457        Ok(())
458    }
459}