eve_esi 0.4.9

Thread-safe, asynchronous client for EVE Online's ESI & OAuth2
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
//! # EVE Online ESI Client Builder
//!
//! This module provides the [`Client`] struct for interacting with the EVE Online ESI (EVE Stable Infrastructure) API.
//!
//! For details on the usage of an [`Client`], see the [client module docs](crate::client).
//!
//! ## References
//! - [ESI API Documentation](https://developers.eveonline.com/api-explorer)
//! - [EVE SSO Documentation](https://developers.eveonline.com/docs/services/sso/)
//!
//! ## Features
//! - Set a user agent to identify your application's requests
//! - Configure [`Client`] for OAuth2 using `client_id`, `client_secret`, and `callback_url` methods
//! - Share a reqwest Client with the ESI client for optimal performance by using the same connection pool
//! - Override the default JWT key cache & refresh settings used to validate OAuth2 tokens & override
//!   the default endpoint URLs with a custom [`Config`] using the [`ClientBuilder::config`] method.
//!
//! ## Builder Methods
//! | Method           | Purpose                                 |
//! | ---------------- | --------------------------------------- |
//! | `new`            | Create a builder for the Client         |
//! | `build`          | Build the Client                        |
//! | `config`         | Override the default config             |
//! | `reqwest_client` | Override default reqwest client         |
//! | `user_agent`     | User agent to identify HTTP requests    |
//! | `client_id`      | EVE OAuth2 client ID                    |
//! | `client_secret`  | EVE OAuth2 client secret                |
//! | `callback_url`   | EVE OAuth2 callback URL                 |
//!
//! ## Usage
//! ### Building an ESI client for OAuth2
//! **Prerequisites:**
//! - **EVE Online Developer Application:** First, you will need to get a client ID & client
//!   secret as well as set the callback URL where your user's will be redirected to after
//!   login. Setup a developer application at <https://developers.eveonline.com/applications>
//! - **Set environment variables:** Never hard-code your client ID & client secret, instead
//!   use a .env file and then load it with the [`dotenvy`](https://crates.io/crates/dotenvy)
//!   crate as demonstrated in the [SSO example](https://github.com/hyziri/eve_esi/blob/main/examples/sso.rs).
//!
//! ```
//! use eve_esi::Client;
//!
//! // Always set a user_agent to identify your application when making requests
//! let user_agent = "MyApp/1.0 (contact@example.com; +https://github.com/your/repository)";
//!
//! // Create an ESI client with the builder method to configure OAuth2 settings
//! let esi_client = Client::builder()
//!     .user_agent(user_agent)
//!     .client_id("client_id")
//!     .client_secret("client_secret")
//!     .callback_url("http://localhost:8080/callback")
//!     .build()
//!     .expect("Failed to build ESI Client");
//! ```
//!
//! ## Warning
//! EVE Online requires setting a proper user agent. Failure to do so may result in rate limiting or API errors.
//! Include application name, version, and contact information in your user agent string as well as the repository
//! of your application if it is open source.
//!
//! Example: `"MyApp/1.0 (contact@example.com; +https://github.com/your/repo)"`

use std::sync::Arc;

use crate::client::ClientRef;
use crate::config::Config;
use crate::error::Error;
use crate::oauth2::jwk::cache::JwtKeyCache;
use crate::Client;

/// Builder for configuring and constructing an [`Client`].
///
/// For a full overview, features, and usage examples, see the [module-level documentation](self).
pub struct ClientBuilder {
    // Base Settings
    /// Config used to override default settings
    pub(crate) config: Option<Config>,
    /// Overrides the default reqwest HTTP client if set to Some()
    pub(crate) reqwest_client: Option<reqwest::Client>,
    /// User agent used for default reqwest client if no client is provided
    pub(crate) user_agent: Option<String>,

    // OAuth2 Settings
    /// Client ID used to identify an EVE Online application
    pub(crate) client_id: Option<String>,
    /// Client secret used to identify an EVE Online application
    pub(crate) client_secret: Option<String>,
    /// URL users are redirected to after the EVE Online login process
    pub(crate) callback_url: Option<String>,
}

impl Default for ClientBuilder {
    /// Create a default instance of [`ClientBuilder`]
    fn default() -> Self {
        Self::new()
    }
}

impl ClientBuilder {
    /// Creates a new [`ClientBuilder`]
    ///
    /// For a full overview, features, and usage examples, see the [module-level documentation](self).
    ///
    /// # Returns
    /// - [`ClientBuilder`]: Instance to modify Client setting using setter methods
    pub fn new() -> Self {
        Self {
            // Base settings
            config: None,
            user_agent: None,
            reqwest_client: None,

            // OAuth2 settings
            client_id: None,
            client_secret: None,
            callback_url: None,
        }
    }

    /// Builds the Client
    ///
    /// For a full overview, features, and usage examples, see the [module-level documentation](self).
    ///
    /// # Returns
    /// - [`Client`]: Instance to interface with EVE Online ESI & OAuth2 endpoints.
    ///
    /// # Error
    /// Returns an [`Error`] if:
    /// - There is a user issue related to building an [`oauth2::Client`] usually due to
    ///   missing OAuth2 settings on [`ClientBuilder`] or invalid URLs configured by a custom [`Config`].
    /// - There is an internal issue building a default [`reqwest::Client`]
    /// - There is an internal issue building a default [`Config`]
    pub fn build(self) -> Result<Client, Error> {
        let mut builder = self;

        // Create a default Config if one is not provided
        let config = match builder.config.take() {
            Some(config) => config,
            None => Config::new()?,
        };

        // Setup a reqwest client
        // Will create a reqwest client with default settings & provided user_agent if builder.reqwest_client is none
        let reqwest_client =
            get_or_default_reqwest_client(builder.reqwest_client.take(), &builder.user_agent)?;

        // Build an OAuth2 client if any OAuth2 settings are configured
        //
        // setup_oauth_client return an error if one setting is configured but another
        // is not as all 3 are required for OAuth2.
        let oauth_client = if builder.client_id.is_some()
            || builder.client_secret.is_some()
            || builder.callback_url.is_some()
        {
            Some(builder.setup_oauth_client(&config)?)
        } else {
            None
        };

        // Setup JWT key cache
        let jwt_key_cache = JwtKeyCache::new(&config);

        // Build ClientRef
        let client_ref = ClientRef {
            reqwest_client,
            esi_url: config.esi_url,
            esi_validate_token_before_request: config.esi_validate_token_before_request,

            // OAuth2
            oauth2_client: oauth_client,
            jwt_key_cache,
            jwt_issuers: config.jwt_issuers,
            jwt_audience: config.jwt_audience,
        };

        // Wrap ClientRef in Client
        Ok(Client {
            inner: Arc::new(client_ref),
        })
    }

    /// Overrides the default [`Client`] settings with a custom config
    ///
    /// If no config is provided to the [`ClientBuilder`], the default settings will be used.
    /// This method allows one to provide a config to override the default settings, for details
    /// on usage & options see the [config module documentation](super::config).
    ///
    /// # Arguments
    /// - `config` ([`Config`]): config used to override default [`Client`] settings
    ///
    /// # Returns
    /// - [`ClientBuilder`]: instance with the updated config
    pub fn config(mut self, config: Config) -> Self {
        self.config = Some(config);
        self
    }

    /// Override the default [`reqwest::Client`] used by [`Client`]
    ///
    /// Use this to configure the HTTP client used by [`Client`] with your
    /// own preferred settings.
    ///
    /// You can create and configure a reqwest client using the [`reqwest::Client::builder`] method.
    ///
    /// # Warning
    /// The [`ClientBuilder::user_agent`] method will not be applied in the
    /// event that a custom reqwest client is provided, instead you should
    /// set the user agent on the provided [`reqwest::Client`] prior to calling
    /// this method.
    ///
    /// # Arguments
    /// - `client` ([`reqwest::Client`]): An HTTP client used to make requests to
    ///   EVE Online's API endpoints.
    ///
    /// # Returns
    /// - [ClientBuilder]: Instance with the configured reqwest client
    pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
        self.reqwest_client = Some(client);
        self
    }

    /// Sets the user agent for the Client.
    ///
    /// This method configures the user agent string used by the default reqwest HTTP client created by
    /// the [`Client`] if a custom reqwest client is not provided with the [`Self::reqwest_client`]
    /// method.
    ///
    /// The user agent string is used to identify the client making requests to the EVE Online API.
    /// A proper user agent should include an app name, version, and contact information.
    ///
    /// Example: `"MyApp/1.0 (contact@example.com; +https://github.com/your/repository)"`
    ///
    /// # Warning
    ///
    /// The user agent set by this method will not be applied to the reqwest client provided by the
    /// [`Self::reqwest_client`] method, instead you should set it on the reqwest client you provide prior.
    ///
    /// EVE Online's ESI API requires setting a proper user agent. Failure to do so may result in rate limiting or API errors.
    ///
    /// # Arguments
    /// - `user_agent` (`&str`): User agent used to identify your application
    ///   when making ESI requests. For example: `"MyApp/1.0 (contact@example.com; +https://github.com/your/repository)"`.
    ///
    /// # Returns
    /// - [`ClientBuilder`]: instance with updated user agent configuration.
    pub fn user_agent(mut self, user_agent: &str) -> Self {
        self.user_agent = Some(user_agent.to_string());
        self
    }

    /// Sets the OAuth2 client ID for authentication with EVE Online SSO.
    ///
    /// This method configures the client ID required for OAuth2 authentication.
    /// You must register your application with EVE Online developers to get a client ID.
    /// <https://developers.eveonline.com/applications>
    ///
    /// # Warning
    /// To enable OAuth2 authentication, you must set `client_id`, `client_secret`, and `callback_url` before calling `.build()`.
    ///
    /// # Arguments
    /// - `client_id` (`&str`): The OAuth2 client ID obtained from the EVE Online developer portal.
    ///
    /// # Returns
    /// - [`ClientBuilder`]: instance with updated client ID configuration.
    pub fn client_id(mut self, client_id: &str) -> Self {
        self.client_id = Some(client_id.to_string());
        self
    }

    /// Sets the OAuth2 client secret for authentication with EVE Online SSO.
    ///
    /// This method configures the client secret required for OAuth2 authentication.
    /// You must register your application with EVE Online developers to get a client secret.
    /// <https://developers.eveonline.com/applications>
    ///
    /// # Warning
    /// To enable OAuth2 authentication, you must set `client_id`, `client_secret`, and `callback_url` before calling `.build()`.
    ///
    /// # Arguments
    /// - `client_secret` (`&str`): The OAuth2 client secret obtained from the EVE Online developer portal.
    ///
    /// # Returns
    /// - [`ClientBuilder`]: instance with updated client secret configuration.
    pub fn client_secret(mut self, client_secret: &str) -> Self {
        self.client_secret = Some(client_secret.to_string());
        self
    }

    /// Sets the callback URL for authentication with EVE Online SSO.
    ///
    /// This method configures the callback URL required for OAuth2 authentication when the user is redirected back to your application.
    /// Ensure that the callback URL matches the one set in your EVE Online developer portal application.
    /// <https://developers.eveonline.com/applications>
    ///
    /// # Warning
    /// To enable OAuth2 authentication, you must set `client_id`, `client_secret`, and `callback_url` before calling `.build()`.
    ///
    /// # Arguments
    /// - `callback_url` (`&str`): The callback URL which matches the one set in your EVE Online developer portal application.
    ///
    /// # Returns
    /// - [`ClientBuilder`] instance with updated callback URL configuration.
    pub fn callback_url(mut self, callback_url: &str) -> Self {
        self.callback_url = Some(callback_url.to_string());
        self
    }
}

/// Utility function that creates a default [`reqwest::Client`] if no client is provided
///
/// Used with the [`ClientBuilder::build`] method to create a default [`reqwest::Client`] with
/// provided user agent if a reqwest custom client has not been provided.
///
/// Provides a warning if both a custom agent and user agent has been provided as the user agent
/// cannot be set on the provided client, the user agent should be set on the provided client prior
/// instead.
///
/// # Arguments
/// - `client` (Option<[`reqwest::Client`]): Option of a reqwest::Client to determine if a default one
///   should be created and returned.
/// - `user_agent` (&Option<[`reqwest::Client`]): Option of a user agent that will be applied to the
///   default reqwest::Client if no `client` is provided.
///
/// # Returns
/// - [`reqwest::Client`]: Either a default reqwest client or the provided one.
///
/// # Errors
/// - [`EsiError`]: If the default [`reqwest::Client`] fails to build
fn get_or_default_reqwest_client(
    client: Option<reqwest::Client>,
    user_agent: &Option<String>,
) -> Result<reqwest::Client, Error> {
    if user_agent.is_some() && client.is_some() {
        warn!(
            "user_agent is set on `ClientBuilder` but so is reqwest_client. The user_agent will not be applied and should be instead applied to the provided reqwest client if not done so already."
        );
    }

    match client {
        Some(client) => Ok(client),
        None => {
            let mut client_builder = reqwest::Client::builder();
            if let Some(agent) = user_agent {
                client_builder = client_builder.user_agent(agent.clone());
            }

            Ok(client_builder.build()?)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{constant::DEFAULT_ESI_URL, ConfigError};

    /// Test default values of the `ClientBuilder`.
    ///
    /// # Setup
    /// - Create an [`ClientBuilder`] with the default values
    ///
    /// # Assertions
    /// - Assert default values are set as expected
    #[test]
    fn test_default_builder_values() {
        // Create an ClientBuilder with the default values
        let builder = ClientBuilder::default();

        // Assert default values are set as expected
        assert!(builder.config.is_none());
        assert!(builder.reqwest_client.is_none());
        assert!(builder.user_agent.is_none());
        assert!(builder.client_id.is_none());
        assert!(builder.client_secret.is_none());
        assert!(builder.callback_url.is_none());
    }

    /// Test setter methods of the [`ClientBuilder`].
    ///
    /// # Setup
    /// - Create a custom [`reqwest::Client`]
    /// - Create a custom [`JwtKeyCache`]
    /// - Creates an [`Client`] with all builder setter methods used
    ///
    /// # Assertions
    /// - Assert base settings are set as expected
    /// - Assert OAuth2 settings are set as expected
    #[test]
    fn test_builder_setter_methods() {
        let custom_reqwest_client = reqwest::Client::new();
        let custom_config = Config::new().expect("Failed to create a default Config");

        let builder = ClientBuilder::new()
            // Base settings
            .config(custom_config)
            .user_agent("MyApp/1.0 (contact@example.com)")
            .reqwest_client(custom_reqwest_client)
            // OAuth2 settings
            .client_id("client_id")
            .client_secret("client_secret")
            .callback_url("http://localhost:8000/callback");

        // Assert base values are set
        assert!(builder.config.is_some());
        assert!(builder.reqwest_client.is_some());
        assert_eq!(
            builder.user_agent,
            Some("MyApp/1.0 (contact@example.com)".to_string())
        );

        // Assert OAuth2 values are set
        assert_eq!(builder.client_id, Some("client_id".to_string()));
        assert_eq!(builder.client_secret, Some("client_secret".to_string()));
        assert_eq!(
            builder.callback_url,
            Some("http://localhost:8000/callback".to_string())
        );
    }

    /// Test successful build with minimal configuration.
    ///
    /// # Setup
    /// - Creates an ESI client builder with minimal configuration.
    ///
    /// # Assertions
    /// - Verifies that the client receives the configured values from the builder.
    #[test]
    fn test_successful_build_minimal() {
        // Test building with just the required user_agent
        let result = ClientBuilder::new()
            .user_agent("MyApp/1.0 (contact@example.com)")
            .build();

        // Assert result is Ok
        assert!(result.is_ok());
        let client = result.unwrap();

        // Assert default ESI_URL is set and oauth client is none
        assert_eq!(client.inner.esi_url, DEFAULT_ESI_URL);
        assert!(client.inner.oauth2_client.is_none());
    }

    /// Test successful build with OAuth configuration.
    ///
    /// # Setup
    /// - Creates an ESI client builder with OAuth configuration.
    ///
    /// # Assertions
    /// - Assert result is ok
    /// - Assert oauth client was initialized
    #[test]
    fn test_successful_build_with_oauth() {
        // Test building with OAuth configuration
        let result = ClientBuilder::new()
            .user_agent("MyApp/1.0 (contact@example.com)")
            .client_id("client_id")
            .client_secret("client_secret")
            .callback_url("http://localhost:8080/callback")
            .build();

        // Assert result is ok
        assert!(result.is_ok());

        // Assert oauth client was initialized
        let client = result.unwrap();
        assert!(client.inner.oauth2_client.is_some());
    }

    /// Test build with a custom config overriding defaults
    ///
    /// # Setup
    /// - Create a config overriding default ESI URL
    /// - Creates an ESI client with the custom config
    ///
    /// # Assertions
    /// - Assert default ESI URL has been overridden
    #[test]
    fn test_build_with_custom_config() {
        // Create a config overriding default ESI URL
        let config = Config::builder()
            .esi_url("https://example.com")
            .build()
            .expect("Failed to create a default Config");

        // Create an ESI client with the custom config
        let result = ClientBuilder::new()
            .config(config)
            .build()
            .expect("Failed to build Client");

        // Assert default ESI URL has been overridden
        assert_ne!(result.inner.esi_url, DEFAULT_ESI_URL);
    }

    /// Test failed build due to partial OAuth configuration.
    ///
    /// # Setup
    /// - Creates an ESI client builder with only the client_id set.
    ///
    /// # Assertions
    /// - Assert result is error
    /// - Assert error is of type ConfigError::MissingClientSecret
    #[test]
    fn test_build_with_partial_oauth_config() {
        // Test that providing only client_id without the other OAuth params fails
        let result = ClientBuilder::new()
            .user_agent("MyApp/1.0 (contact@example.com)")
            .client_id("client_id")
            .build();

        // Assert result is error
        assert!(result.is_err());

        // Assert error is of type ConfigError::MissingClientSecret
        assert!(matches!(
            result,
            Err(Error::ConfigError(ConfigError::MissingClientSecret))
        ));
    }
}

#[cfg(test)]
mod get_or_default_reqwest_client_tests {
    use crate::builder::get_or_default_reqwest_client;

    /// Ensures a [`reqwest::Client`] is returned when a reqwest client & user agent is provided
    ///
    /// # Test Setup
    /// - Build a custom [`reqwest::Client`]
    /// - Call function with a custom [`reqwest::Client`] and providing a `user_agent`
    ///
    /// # Assert
    /// - Assert result is Ok indicating a reqwest client was returned without issues
    #[test]
    fn test_custom_client_and_agent() {
        let user_agent = "MyApp/1.0 (contact@example.com)".to_string();
        let timeout = std::time::Duration::from_secs(10);

        // Build a reqwest client with custom settings
        let client = reqwest::Client::builder()
            // Always set user_agent when providing a custom client to get_or_default_reqwest_client
            // as the function is unable to modify the provided client further.
            .user_agent(&user_agent)
            .timeout(timeout)
            .build()
            .expect("Failed to build reqwest::Client");

        // Call function
        //
        // The provided agent won't be used but we'll add it to make sure the warning execution path is called
        let result = get_or_default_reqwest_client(Some(client), &Some(user_agent));

        // Assert result is Ok
        assert!(result.is_ok());
    }

    /// Ensures a default [`reqwest::Client`] is returned if no client is provided
    ///
    /// # Test Setup
    /// - Call function with `client` set as None and providing a `user_agent`
    ///
    /// # Assert
    /// - Assert result is Ok indicating a default reqwest client with default settings has been returned
    #[test]
    fn test_default_with_agent() {
        let result = get_or_default_reqwest_client(None, &Some("Agent".to_string()));

        // Assert result is Ok
        assert!(result.is_ok());
    }
}