chat_gpt_lib_rs/
config.rs

1//! The `config` module provides functionality for configuring and creating the [`OpenAIClient`],
2//! including handling API keys, organization IDs, timeouts, and base URLs.
3//
4//! # Overview
5//!
6//! This module exposes the [`OpenAIClient`] struct, which is your main entry point for interacting
7//! with the OpenAI API. It provides a builder-pattern (`ClientBuilder`) for customizing various
8//! aspects of the client configuration, such as the API key, organization ID, timeouts, and so on.
9//
10//! # Usage
11//!
12//! ```rust
13//! use chat_gpt_lib_rs::OpenAIClient;
14//!
15//! #[tokio::main]
16//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
17//!      // Load environment variables from a .env file, if present (optional).
18//!      dotenvy::dotenv().ok();
19//!
20//!     // Example 1: Use environment variable `OPENAI_API_KEY`.
21//!     let client = OpenAIClient::new(None)?;
22//!
23//!     // Example 2: Use a builder pattern to set more configuration.
24//!     let client_with_org = OpenAIClient::builder()
25//!         .with_api_key("sk-...YourKey...")
26//!         .with_organization("org-MyOrganization")
27//!         .with_timeout(std::time::Duration::from_secs(30))
28//!         .build()?;
29//!
30//!     // Use `client` or `client_with_org` to make API requests...
31//!
32//!     Ok(())
33//! }
34//! ```
35
36use std::env;
37use std::time::Duration;
38
39use reqwest::{Client, ClientBuilder as HttpClientBuilder};
40
41use crate::error::OpenAIError;
42
43/// The default base URL for the OpenAI API.
44///
45/// You can override this in the builder if needed (e.g., for proxies or mock servers).
46pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1/";
47
48/// A client for interacting with the OpenAI API.
49///
50/// This struct holds the configuration (e.g., API key, organization ID, base URL) and
51/// an underlying [`reqwest::Client`] for making HTTP requests. Typically, you'll create an
52/// `OpenAIClient` using:
53/// 1) The [`OpenAIClient::new`] method, which optionally reads the API key from an environment variable, or
54/// 2) The builder pattern via [`OpenAIClient::builder`].
55#[derive(Clone, Debug)]
56pub struct OpenAIClient {
57    /// The full base URL used for OpenAI endpoints (e.g. "https://api.openai.com/v1/").
58    base_url: String,
59    /// The API key used for authentication (e.g., "sk-...").
60    api_key: String,
61    /// Optional organization ID, if applicable to your account.
62    organization: Option<String>,
63    /// The underlying HTTP client from `reqwest`, configured with timeouts, TLS, etc.
64    pub(crate) http_client: Client,
65}
66
67impl OpenAIClient {
68    /// Creates a new `OpenAIClient` using the provided API key, or reads it from the
69    /// `OPENAI_API_KEY` environment variable if `api_key` is `None`.
70    ///
71    /// # Errors
72    ///
73    /// Returns an [`OpenAIError`] if no API key can be found in the given argument or
74    /// the environment variable.
75    ///
76    /// # Examples
77    ///
78    /// ```rust
79    /// use chat_gpt_lib_rs::OpenAIClient;
80    /// // load environment variables from a .env file, if present (optional).
81    /// dotenvy::dotenv().ok();
82    ///
83    /// // Reads `OPENAI_API_KEY` from the environment.
84    /// let client = OpenAIClient::new(None).unwrap();
85    ///
86    /// // Provide an explicit API key.
87    /// let client = OpenAIClient::new(Some("sk-...".to_string())).unwrap();
88    /// ```
89    pub fn new(api_key: Option<String>) -> Result<Self, OpenAIError> {
90        let key = match api_key {
91            Some(k) => k,
92            None => env::var("OPENAI_API_KEY")
93                .map_err(|_| OpenAIError::ConfigError("Missing API key".to_string()))?,
94        };
95
96        let http_client = HttpClientBuilder::new()
97            .build()
98            .map_err(|e| OpenAIError::ConfigError(e.to_string()))?;
99
100        Ok(Self {
101            base_url: DEFAULT_BASE_URL.to_string(),
102            api_key: key,
103            organization: None,
104            http_client,
105        })
106    }
107
108    /// Returns a new [`ClientBuilder`] to configure and build an `OpenAIClient`.
109    ///
110    /// # Examples
111    ///
112    /// ```rust
113    /// # use chat_gpt_lib_rs::OpenAIClient;
114    /// let client = OpenAIClient::builder()
115    ///     .with_api_key("sk-EXAMPLE")
116    ///     .with_organization("org-EXAMPLE")
117    ///     .build()
118    ///     .unwrap();
119    /// ```
120    pub fn builder() -> ClientBuilder {
121        ClientBuilder::default()
122    }
123
124    /// Returns the current base URL as a string slice.
125    ///
126    /// Useful if you need to verify or debug the client's configuration.
127    pub fn base_url(&self) -> &str {
128        &self.base_url
129    }
130
131    /// Returns the API key as a string slice.
132    ///
133    /// For security reasons, you might not want to expose this in production logs.
134    pub fn api_key(&self) -> &str {
135        &self.api_key
136    }
137
138    /// Returns the optional organization ID, if it was set.
139    pub fn organization(&self) -> Option<&str> {
140        self.organization.as_deref()
141    }
142}
143
144/// A builder for [`OpenAIClient`] that follows the builder pattern.
145///
146/// # Examples
147///
148/// ```rust
149/// use chat_gpt_lib_rs::OpenAIClient;
150/// use std::time::Duration;
151///
152/// let client = OpenAIClient::builder()
153///     .with_api_key("sk-...YourKey...")
154///     .with_organization("org-MyOrganization")
155///     .with_timeout(Duration::from_secs(30))
156///     .build()
157///     .unwrap();
158/// ```
159#[derive(Default, Debug)]
160pub struct ClientBuilder {
161    base_url: Option<String>,
162    api_key: Option<String>,
163    organization: Option<String>,
164    timeout: Option<Duration>,
165}
166
167impl ClientBuilder {
168    /// Sets a custom base URL for all OpenAI requests.
169    ///
170    /// # Example
171    ///
172    /// ```rust
173    /// # use chat_gpt_lib_rs::OpenAIClient;
174    /// let client = OpenAIClient::builder()
175    ///     .with_base_url("https://custom-openai-proxy.example.com/v1/")
176    ///     .with_api_key("sk-EXAMPLE")
177    ///     .build()
178    ///     .unwrap();
179    /// ```
180    pub fn with_base_url(mut self, url: &str) -> Self {
181        self.base_url = Some(url.to_string());
182        self
183    }
184
185    /// Sets the API key explicitly. If not provided, the client will attempt to
186    /// read from the `OPENAI_API_KEY` environment variable.
187    ///
188    /// # Example
189    ///
190    /// ```rust
191    /// # use chat_gpt_lib_rs::OpenAIClient;
192    /// let client = OpenAIClient::builder()
193    ///     .with_api_key("sk-EXAMPLE")
194    ///     .build()
195    ///     .unwrap();
196    /// ```
197    pub fn with_api_key(mut self, key: &str) -> Self {
198        self.api_key = Some(key.to_string());
199        self
200    }
201
202    /// Sets the organization ID for the client. Some accounts or requests
203    /// require specifying an organization ID.
204    ///
205    /// # Example
206    ///
207    /// ```rust
208    /// # use chat_gpt_lib_rs::OpenAIClient;
209    /// let client = OpenAIClient::builder()
210    ///     .with_api_key("sk-EXAMPLE")
211    ///     .with_organization("org-EXAMPLE")
212    ///     .build()
213    ///     .unwrap();
214    /// ```
215    pub fn with_organization(mut self, org: &str) -> Self {
216        self.organization = Some(org.to_string());
217        self
218    }
219
220    /// Sets a timeout for all HTTP requests made by this client.
221    /// If not specified, the timeout behavior of the underlying
222    /// [`reqwest::Client`] defaults are used.
223    ///
224    /// # Example
225    ///
226    /// ```rust
227    /// # use chat_gpt_lib_rs::OpenAIClient;
228    /// # use std::time::Duration;
229    /// let client = OpenAIClient::builder()
230    ///     .with_api_key("sk-EXAMPLE")
231    ///     .with_timeout(Duration::from_secs(30))
232    ///     .build()
233    ///     .unwrap();
234    /// ```
235    pub fn with_timeout(mut self, duration: Duration) -> Self {
236        self.timeout = Some(duration);
237        self
238    }
239
240    /// Builds the [`OpenAIClient`] using the specified configuration.
241    ///
242    /// If the API key is not set through `with_api_key`, it attempts to read from
243    /// the `OPENAI_API_KEY` environment variable. If no key is found, an error is returned.
244    ///
245    /// # Errors
246    ///
247    /// Returns an [`OpenAIError`] if no API key is provided or discovered in the environment,
248    /// or if building the underlying HTTP client fails.
249    pub fn build(self) -> Result<OpenAIClient, OpenAIError> {
250        // Determine the base URL
251        let base_url = self
252            .base_url
253            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
254
255        // Determine the API key
256        let api_key = match self.api_key {
257            Some(k) => k,
258            None => env::var("OPENAI_API_KEY")
259                .map_err(|_| OpenAIError::ConfigError("Missing API key".to_string()))?,
260        };
261
262        let organization = self.organization;
263
264        // Build the reqwest Client with optional timeout
265        let mut http_client_builder = HttpClientBuilder::new();
266        if let Some(to) = self.timeout {
267            http_client_builder = http_client_builder.timeout(to);
268        }
269
270        // Build the reqwest client
271        let http_client = http_client_builder
272            .build()
273            .map_err(|e| OpenAIError::ConfigError(e.to_string()))?;
274
275        Ok(OpenAIClient {
276            base_url,
277            api_key,
278            organization,
279            http_client,
280        })
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    //! # Tests for the `config` module
287    //!
288    //! These tests verify that the [`OpenAIClient`] and its builder can:
289    //! - Correctly derive API keys from environment variables or explicit parameters
290    //! - Respect custom base URLs, organization IDs, and timeouts
291    //! - Return proper errors (`OpenAIError::ConfigError`) if configuration fails
292    //!
293    //! We rely on standard library features (`std::env`) to manipulate environment variables
294    //! for testing. We do not mock any network calls here because the configuration layer
295    //! does not connect to real endpoints.
296
297    use super::*;
298    use crate::error::OpenAIError;
299    use serial_test::serial; // <-- Use the serial_test attribute to run tests serially
300    use std::sync::{Mutex, OnceLock};
301
302    // Global lock to serialize environment variable access.
303    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
304
305    /// Returns a reference to a global mutex used to synchronize environment modifications.
306    fn get_env_lock() -> &'static Mutex<()> {
307        ENV_LOCK.get_or_init(|| Mutex::new(()))
308    }
309
310    /// A safe wrapper for setting or removing an environment variable.
311    ///
312    /// Although `std::env::set_var` and `std::env::remove_var` are unsafe in Rust 2024,
313    /// this function wraps them inside a controlled environment. The global lock ensures that
314    /// no concurrent access occurs, making our usage safe for tests.
315    fn safe_set_env_var(key: &str, value: Option<&str>) {
316        match value {
317            Some(v) => {
318                // Encapsulate the unsafe call
319                unsafe {
320                    std::env::set_var(key, v);
321                }
322            }
323            None => unsafe {
324                std::env::remove_var(key);
325            },
326        }
327    }
328
329    /// Temporarily sets an environment variable, runs the provided closure,
330    /// and then restores the original state.
331    ///
332    /// The global lock (and use of the `serial_test` attribute on tests) guarantees exclusive access
333    /// to environment modifications during the test.
334    fn with_temp_env_var<F: FnOnce()>(key: &str, value: Option<&str>, test_fn: F) {
335        // Acquire the lock to ensure exclusive access to the environment.
336        let _lock = get_env_lock().lock().unwrap();
337        let old_value = std::env::var(key).ok();
338        safe_set_env_var(key, value);
339        test_fn();
340        safe_set_env_var(key, old_value.as_deref());
341        // The lock is automatically released here.
342    }
343
344    #[test]
345    #[serial] // Ensures tests that modify the environment run sequentially.
346    fn test_temp_env_var() {
347        with_temp_env_var("MY_TEST_VAR", Some("test_value"), || {
348            assert_eq!(std::env::var("MY_TEST_VAR").unwrap(), "test_value");
349        });
350        // Outside the closure, the environment variable is restored.
351    }
352
353    #[test]
354    fn test_new_with_explicit_key() {
355        let client = OpenAIClient::new(Some("sk-test-explicit".to_string())).unwrap();
356        assert_eq!(client.api_key(), "sk-test-explicit");
357        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
358        assert!(client.organization().is_none());
359    }
360
361    // Mark environment-sensitive tests with #[serial]
362    #[test]
363    #[serial]
364    fn test_new_with_env_var() {
365        with_temp_env_var("OPENAI_API_KEY", Some("sk-from-env"), || {
366            let client = OpenAIClient::new(None).unwrap();
367            assert_eq!(client.api_key(), "sk-from-env");
368        });
369    }
370
371    #[test]
372    #[serial]
373    fn test_new_missing_api_key() {
374        with_temp_env_var("OPENAI_API_KEY", None, || {
375            let err = OpenAIClient::new(None).unwrap_err();
376            match err {
377                OpenAIError::ConfigError(msg) => {
378                    assert!(
379                        msg.contains("Missing API key"),
380                        "Unexpected error message: {msg}"
381                    );
382                }
383                other => panic!("Expected ConfigError, got: {:?}", other),
384            }
385        });
386    }
387
388    #[test]
389    fn test_builder_with_all_fields() {
390        let client = OpenAIClient::builder()
391            .with_api_key("sk-builder")
392            .with_base_url("https://custom.example.com/v1/")
393            .with_organization("org-xyz")
394            .with_timeout(Duration::from_secs(60))
395            .build()
396            .unwrap();
397
398        assert_eq!(client.api_key(), "sk-builder");
399        assert_eq!(client.base_url(), "https://custom.example.com/v1/");
400        assert_eq!(client.organization(), Some("org-xyz"));
401    }
402
403    #[test]
404    fn test_builder_uses_default_base_url() {
405        // If not specified, it should fall back to DEFAULT_BASE_URL
406        let client = OpenAIClient::builder()
407            .with_api_key("sk-nokey")
408            .build()
409            .unwrap();
410
411        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
412    }
413
414    #[test]
415    #[serial]
416    fn test_builder_no_explicit_key_no_env() {
417        // Removing env var, expecting an error
418        with_temp_env_var("OPENAI_API_KEY", None, || {
419            let err = OpenAIClient::builder().build().unwrap_err();
420            match err {
421                OpenAIError::ConfigError(msg) => {
422                    assert!(
423                        msg.contains("Missing API key"),
424                        "Expected missing API key message, got: {msg}"
425                    );
426                }
427                other => panic!("Expected ConfigError, got: {:?}", other),
428            }
429        });
430    }
431
432    #[test]
433    #[serial]
434    fn test_builder_with_env_fallback() {
435        with_temp_env_var("OPENAI_API_KEY", Some("sk-env-fallback"), || {
436            let client = OpenAIClient::builder().build().unwrap();
437            assert_eq!(client.api_key(), "sk-env-fallback");
438            // Base URL defaults
439            assert_eq!(client.base_url(), DEFAULT_BASE_URL);
440        });
441    }
442}