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}