Skip to main content

esi_openapi/
builders.rs

1//! Builders
2
3use crate::{prelude::*, spec::Spec};
4use reqwest::{header, Client};
5use std::time::Duration;
6
7/// Builder for the `Esi` struct.
8///
9/// # Example
10///
11/// ```rust
12/// # use esi_openapi::prelude::EsiBuilder;
13/// let mut esi = EsiBuilder::new()
14///     .user_agent("some user agent")
15///     .client_id("your_client_id")
16///     .client_secret("your_client_secret")
17///     .callback_url("your_callback_url")
18///     .build()
19///     .unwrap();
20/// ```
21///
22/// # Overriding the API specification
23///
24/// If you will be creating multiple struct instances,
25/// you will probably run into the issue of needing to
26/// retrieve the API spec on each. Given that this
27/// operation is fairly expensive (certainly as far as
28/// the rest of the API endpoints that EVE makes available),
29/// this builder supports setting that struct:
30///
31/// ```rust
32/// # use esi_openapi::prelude::EsiBuilder;
33/// # let your_spec = serde_json::from_str(r#"{"paths": {}}"#).unwrap();
34/// let mut esi = EsiBuilder::new()
35///     .user_agent("some user agent")
36///     .spec(Some(your_spec))
37///     .build()
38///     .unwrap();
39/// ```
40///
41/// Note that this "spec" function is just another builder
42/// function; you can make use it alongside all of the others.
43/// Note also that this is entirely optional: if you don't
44/// mind retrieving the spec from ESI on each of your
45/// struct instances, you can safely ignore this.
46///
47/// # Not including client info
48///
49/// If you are only making calls to non-authenticated
50/// endpoints, then you don't need to make use of
51/// the authentication flow, which means you don't need
52/// a client ID, client secret, and callback URL.
53/// In this case, you can construct your client without
54/// those parameters:
55///
56/// ```rust
57/// # use esi_openapi::prelude::EsiBuilder;
58/// let mut esi = EsiBuilder::new()
59///     .user_agent("some user agent")
60///     .build()
61///     .unwrap();
62/// ```
63///
64/// Note that you still need to set the user agent, as this is good
65/// API usage behavior.
66#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
67pub struct EsiBuilder {
68    pub(crate) compatibility_date: Option<String>,
69    pub(crate) client_id: Option<String>,
70    pub(crate) client_secret: Option<String>,
71    pub(crate) application_auth: Option<bool>,
72    pub(crate) callback_url: Option<String>,
73    pub(crate) base_api_url: Option<String>,
74    pub(crate) authorize_url: Option<String>,
75    pub(crate) token_url: Option<String>,
76    pub(crate) spec_url: Option<String>,
77    pub(crate) scope: Option<String>,
78    pub(crate) access_token: Option<String>,
79    pub(crate) access_expiration: Option<i64>,
80    pub(crate) refresh_token: Option<String>,
81    pub(crate) user_agent: Option<String>,
82    pub(crate) http_timeout: Option<u64>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub(crate) cache_enabled: Option<bool>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub(crate) cache_max_entries: Option<usize>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub(crate) cache_max_bytes: Option<usize>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub(crate) rate_limit_policy: Option<RateLimitPolicy>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub(crate) page_concurrency: Option<usize>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub(crate) language: Option<Language>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub(crate) tenant: Option<String>,
97    pub(crate) spec: Option<Spec>,
98}
99
100/// Languages ESI can answer in (the `Accept-Language` header).
101#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
102#[serde(rename_all = "lowercase")]
103pub enum Language {
104    /// English (the default).
105    #[default]
106    En,
107    /// German.
108    De,
109    /// French.
110    Fr,
111    /// Japanese.
112    Ja,
113    /// Russian.
114    Ru,
115    /// Chinese.
116    Zh,
117    /// Korean.
118    Ko,
119    /// Spanish.
120    Es,
121}
122
123impl Language {
124    /// The value of the `Accept-Language` header.
125    pub fn as_str(&self) -> &'static str {
126        match self {
127            Language::En => "en",
128            Language::De => "de",
129            Language::Fr => "fr",
130            Language::Ja => "ja",
131            Language::Ru => "ru",
132            Language::Zh => "zh",
133            Language::Ko => "ko",
134            Language::Es => "es",
135        }
136    }
137}
138
139impl EsiBuilder {
140    /// Start a new builder.
141    pub fn new() -> Self {
142        Default::default()
143    }
144
145    /// Set the compatibility header to use.
146    ///
147    /// Will default to a hardcoded value if not set.
148    pub fn compatibility_date(mut self, val: &str) -> Self {
149        self.compatibility_date = Some(val.to_owned());
150        self
151    }
152
153    /// Set the client_id.
154    pub fn client_id(mut self, val: &str) -> Self {
155        self.client_id = Some(val.to_owned());
156        self
157    }
158
159    /// Set the client_secret (https://docs.esi.evetech.net/docs/sso/web_based_sso_flow.html).
160    pub fn client_secret(mut self, val: &str) -> Self {
161        self.client_secret = Some(val.to_owned());
162        self
163    }
164
165    /// Enable PKCE Authentication flow for Applications (https://docs.esi.evetech.net/docs/sso/native_sso_flow.html)
166    pub fn enable_application_authentication(mut self, val: bool) -> Self {
167        self.application_auth = Some(val);
168        self
169    }
170
171    /// Set the callback_url.
172    pub fn callback_url(mut self, val: &str) -> Self {
173        self.callback_url = Some(val.to_owned());
174        self
175    }
176
177    /// Set the base_api_url.
178    pub fn base_api_url(mut self, val: &str) -> Self {
179        self.base_api_url = Some(val.to_owned());
180        self
181    }
182
183    /// Set the authorize_url.
184    pub fn authorize_url(mut self, val: &str) -> Self {
185        self.authorize_url = Some(val.to_owned());
186        self
187    }
188
189    /// Set the token_url.
190    pub fn token_url(mut self, val: &str) -> Self {
191        self.token_url = Some(val.to_owned());
192        self
193    }
194
195    /// Set the spec_url.
196    pub fn spec_url(mut self, val: &str) -> Self {
197        self.spec_url = Some(val.to_owned());
198        self
199    }
200
201    /// Set the scope.
202    pub fn scope(mut self, val: &str) -> Self {
203        self.scope = Some(val.to_owned().replace(' ', "%20"));
204        self
205    }
206
207    /// Set the access_token.
208    pub fn access_token(mut self, val: Option<&str>) -> Self {
209        self.access_token = val.map(|v| v.to_owned());
210        self
211    }
212
213    /// Set the access_expiration.
214    pub fn access_expiration(mut self, val: Option<i64>) -> Self {
215        self.access_expiration = val;
216        self
217    }
218
219    /// Set the refresh_token.
220    pub fn refresh_token(mut self, val: Option<&str>) -> Self {
221        self.refresh_token = val.map(|v| v.to_owned());
222        self
223    }
224
225    /// Set the user_agent.
226    pub fn user_agent(mut self, val: &str) -> Self {
227        self.user_agent = Some(val.to_owned());
228        self
229    }
230
231    /// Set the timeout to use in millis when sending HTTP requests.
232    ///
233    /// Will default to 60,000 (1 minute) if not set.
234    pub fn http_timeout(mut self, val: Option<u64>) -> Self {
235        self.http_timeout = val;
236        self
237    }
238
239    /// Explicitly set the OpenAPI specification.
240    ///
241    /// Allows copying the spec from another `Esi` struct
242    /// or other source, avoiding fetching the spec from
243    /// ESI again. May be useful given the runtime cost
244    /// of retrieving the spec.
245    ///
246    /// Be aware of the potential for out-of-date data.
247    pub fn spec(mut self, spec: Option<Spec>) -> Self {
248        self.spec = spec;
249        self
250    }
251
252    pub(crate) fn construct_client(&self) -> EsiResult<Client> {
253        let http_timeout = self
254            .http_timeout
255            .map(Duration::from_millis)
256            .unwrap_or_else(|| Duration::from_secs(60));
257        let headers = {
258            let mut map = header::HeaderMap::new();
259            let user_agent = &self
260                .user_agent
261                .as_ref()
262                .ok_or_else(|| EsiError::EmptyClientValue("user_agent".to_owned()))?
263                .to_owned();
264            map.insert(
265                header::USER_AGENT,
266                header::HeaderValue::from_str(user_agent)?,
267            );
268            map.insert(
269                header::ACCEPT,
270                header::HeaderValue::from_static("application/json"),
271            );
272            map
273        };
274        let builder = Client::builder()
275            .timeout(http_timeout)
276            .default_headers(headers);
277        #[cfg(feature = "rustls-tls")]
278        let builder = builder.tls_backend_rustls();
279        Ok(builder.build()?)
280    }
281
282    /// Limit how many responses the cache keeps (1024 by default). When it is
283    /// full, expired entries are dropped first and then the oldest ones.
284    /// Has no effect unless the cache is enabled with [`EsiBuilder::enable_cache`].
285    pub fn cache_max_entries(mut self, val: usize) -> Self {
286        self.cache_max_entries = Some(val);
287        self
288    }
289
290    /// Limit how many bytes the cache keeps (no limit by default), counting the
291    /// bodies, validators, headers and keys it stores. It applies together with
292    /// [`EsiBuilder::cache_max_entries`]: when a response does not fit in either
293    /// limit, entries are dropped in the same order. A response larger than the
294    /// limit by itself is not cached. Has no effect unless the cache is enabled
295    /// with [`EsiBuilder::enable_cache`].
296    pub fn cache_max_bytes(mut self, val: usize) -> Self {
297        self.cache_max_bytes = Some(val);
298        self
299    }
300
301    /// Choose what the client does when a route group has no rate-limit tokens
302    /// left for a request ([`RateLimitPolicy::Off`] by default): wait until the
303    /// request fits, or fail without calling ESI.
304    ///
305    /// The balance is tracked per route group and access token, from the
306    /// `X-Ratelimit-*` headers of the responses, and only for operations that
307    /// declare a group in the spec, so the spec must be loaded.
308    pub fn rate_limit_policy(mut self, val: RateLimitPolicy) -> Self {
309        self.rate_limit_policy = Some(val);
310        self
311    }
312
313    /// How many pages `Esi::fetch_all_pages` requests at the same time once it
314    /// knows the page count (4 by default; 1 makes it sequential).
315    pub fn page_concurrency(mut self, val: usize) -> Self {
316        self.page_concurrency = Some(val);
317        self
318    }
319
320    /// Set the language of the responses (the `Accept-Language` header).
321    ///
322    /// ESI answers in English when it is not set.
323    pub fn language(mut self, val: Language) -> Self {
324        self.language = Some(val);
325        self
326    }
327
328    /// Set the tenant (the `X-Tenant` header). ESI uses `tranquility` when it is
329    /// not set.
330    pub fn tenant(mut self, val: &str) -> Self {
331        self.tenant = Some(val.to_owned());
332        self
333    }
334
335    /// Keep `GET` responses in memory and revalidate them with `ETag` /
336    /// `Last-Modified` (disabled by default).
337    ///
338    /// While an entry is younger than the operation's `x-client-cache-ttl` in the
339    /// spec (or the `max-age` of the response when the spec has none) it is served
340    /// without calling ESI; afterwards it is revalidated and a `304 Not Modified`
341    /// reuses the stored body. Entries are kept per access token.
342    pub fn enable_cache(mut self, val: bool) -> Self {
343        self.cache_enabled = Some(val);
344        self
345    }
346
347    /// Construct the `Esi` instance.
348    ///
349    /// There are a few things that could go wrong, like
350    /// not setting one of the mandatory fields or providing a user
351    /// agent that is not a valid HTTP header value.
352    pub fn build(self) -> EsiResult<Esi> {
353        Esi::from_builder(self)
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::EsiBuilder;
360    use crate::spec::Spec;
361
362    #[test]
363    fn test_language_and_tenant() {
364        let esi = EsiBuilder::new()
365            .user_agent("d")
366            .language(super::Language::De)
367            .tenant("singularity")
368            .build()
369            .unwrap();
370        assert_eq!(esi.language, Some(super::Language::De));
371        assert_eq!(esi.tenant.as_deref(), Some("singularity"));
372        assert_eq!(super::Language::Ko.as_str(), "ko");
373        let json = serde_json::to_string(&EsiBuilder::new().language(super::Language::Fr)).unwrap();
374        assert!(json.contains("\"language\":\"fr\""));
375    }
376
377    #[test]
378    fn test_builder_valid() {
379        let b = EsiBuilder::new()
380            .client_id("a")
381            .client_secret("b")
382            .callback_url("c")
383            .user_agent("d")
384            .build()
385            .unwrap();
386
387        assert_eq!(b.client_id, Some(String::from("a")));
388        assert_eq!(b.client_secret, Some(String::from("b")));
389        assert_eq!(b.callback_url, Some(String::from("c")));
390        assert_eq!(b.compatibility_date, "2026-08-18");
391        assert_eq!(b.access_token, None);
392        assert_eq!(b.spec, None);
393    }
394
395    #[test]
396    fn test_builder_no_client() {
397        let b = EsiBuilder::new().user_agent("d").build().unwrap();
398
399        assert_eq!(b.client_id, None);
400        assert_eq!(b.client_secret, None);
401        assert_eq!(b.callback_url, None);
402        assert_eq!(b.base_api_url, "https://esi.evetech.net/");
403        assert_eq!(
404            b.authorize_url,
405            "https://login.eveonline.com/v2/oauth/authorize"
406        );
407        assert_eq!(b.token_url, "https://login.eveonline.com/v2/oauth/token");
408        assert_eq!(b.spec_url, "https://esi.evetech.net/meta/openapi.json");
409        assert_eq!(b.compatibility_date, "2026-08-18");
410        assert_eq!(b.access_token, None);
411        assert_eq!(b.spec, None);
412    }
413
414    #[test]
415    fn test_builder_change_urls() {
416        let b = EsiBuilder::new()
417            .user_agent("d")
418            .base_api_url("http://eve-api/")
419            .authorize_url("http://authorize-url/")
420            .token_url("http://token-url")
421            .spec_url("http://spec-url/")
422            .build()
423            .unwrap();
424
425        assert_eq!(b.base_api_url, "http://eve-api/");
426        assert_eq!(b.authorize_url, "http://authorize-url/");
427        assert_eq!(b.token_url, "http://token-url");
428        assert_eq!(b.spec_url, "http://spec-url/");
429    }
430
431    #[test]
432    fn test_builder_missing_value() {
433        let res = EsiBuilder::new().build();
434        assert!(res.is_err());
435        let s = format!("{}", res.unwrap_err());
436        assert_eq!(s, "Missing required builder struct value 'user_agent'");
437    }
438
439    #[test]
440    fn test_builder_with_spec() {
441        let spec: Spec = serde_json::from_str(r#"{"paths": {}}"#).unwrap();
442        let b = EsiBuilder::new()
443            .user_agent("d")
444            .spec(Some(spec.clone()))
445            .build()
446            .unwrap();
447
448        assert_eq!(spec, b.spec.unwrap());
449    }
450
451    #[test]
452    fn test_builder_to_json_empty() {
453        let json = r#"{"compatibility_date":null,"client_id":null,"client_secret":null,"application_auth":null,"callback_url":null,"base_api_url":null,"authorize_url":null,"token_url":null,"spec_url":null,"scope":null,"access_token":null,"access_expiration":null,"refresh_token":null,"user_agent":null,"http_timeout":null,"spec":null}"#;
454        assert_eq!(json, serde_json::to_string(&EsiBuilder::new()).unwrap());
455    }
456
457    #[test]
458    fn test_builder_from_json_filled() {
459        let json = r#"{
460            "compatibility_date": "2026-08-18",
461            "client_id": "a",
462            "client_secret": "b",
463            "callback_url": "c",
464            "scope": "d",
465            "access_token": "e",
466            "access_expiration": 1,
467            "refresh_token": "f",
468            "user_agent": "g",
469            "http_timeout": 60000,
470            "spec": null
471          }"#;
472        let actual: EsiBuilder = serde_json::from_str(json).unwrap();
473        let expected = EsiBuilder::new()
474            .compatibility_date("2026-08-18")
475            .client_id("a")
476            .client_secret("b")
477            .callback_url("c")
478            .scope("d")
479            .access_token(Some("e"))
480            .access_expiration(Some(1))
481            .refresh_token(Some("f"))
482            .user_agent("g")
483            .http_timeout(Some(60_000))
484            .spec(None);
485
486        assert_eq!(actual, expected);
487    }
488}