Skip to main content

esi_openapi/
client.rs

1//! Main logic
2
3use crate::{
4    cache::{CacheEntry, ResponseCache},
5    client::ErrorLimitStatus::{Limited, NotLimited},
6    cursor_page::CursorPage,
7    groups::*,
8    legacy,
9    pkce::{self, PkceVerifier},
10    prelude::*,
11    rate_limiter::{Acquire, Permit, RateLimiter},
12    spec::{Spec, SpecIndex},
13};
14use base64::engine::{general_purpose::STANDARD as base64, Engine};
15use log::{debug, error, warn};
16#[cfg(feature = "random_state")]
17use rand::{distr::Alphanumeric, RngExt};
18use reqwest::{
19    header::{self, HeaderMap, HeaderValue},
20    Client, Method,
21};
22use serde::{de::DeserializeOwned, Serialize};
23use std::{
24    collections::HashMap,
25    str::FromStr,
26    sync::Arc,
27    time::{SystemTime, UNIX_EPOCH},
28};
29use tokio::sync::RwLock;
30
31const BASE_URL: &str = "https://esi.evetech.net/";
32const AUTHORIZE_URL: &str = "https://login.eveonline.com/v2/oauth/authorize";
33const TOKEN_URL: &str = "https://login.eveonline.com/v2/oauth/token";
34const SPEC_URL: &str = "https://esi.evetech.net/meta/openapi.json";
35const ERROR_LIMIT_REMAIN_HEADER: &str = "x-esi-error-limit-remain";
36const ERROR_LIMIT_RESET_HEADER: &str = "x-esi-error-limit-reset";
37const RATE_LIMIT_GROUP_HEADER: &str = "x-ratelimit-group";
38const RATE_LIMIT_LIMIT_HEADER: &str = "x-ratelimit-limit";
39const RATE_LIMIT_REMAINING_HEADER: &str = "x-ratelimit-remaining";
40const RATE_LIMIT_USED_HEADER: &str = "x-ratelimit-used";
41
42static COMPATIBILITY_HEADER: &str = "X-Compatibility-Date";
43static TENANT_HEADER: &str = "X-Tenant";
44/// The largest `limit` the spec allows on cursor-paginated operations.
45const CURSOR_PAGE_LIMIT: &str = "100";
46/// The default compatibility date to use if none is specified
47/// in the builder: the latest date listed by
48/// `https://esi.evetech.net/meta/compatibility-dates` when this
49/// version was released.
50pub const COMPATIBILITY_DATE_DEFAULT: &str = "2026-08-18";
51
52/// Response from SSO when exchanging a SSO code for tokens.
53#[derive(Debug, Deserialize)]
54struct AuthenticateResponse {
55    access_token: String,
56    expires_in: u64,
57    refresh_token: Option<String>,
58}
59
60/// Response from SSO when exchanging a refresh token for access token.
61#[derive(Debug, Deserialize)]
62struct RefreshTokenAuthenticateResponse {
63    access_token: String,
64    expires_in: u64,
65    refresh_token: String,
66}
67
68#[derive(Copy, Clone, Debug)]
69struct ErrorLimitState {
70    remaining_limit: i32,
71    expires_at_millis: i64,
72}
73
74/// Whether ESI's legacy error limit (`X-Esi-Error-Limit-*` headers) is
75/// currently blocking requests from this client.
76#[derive(Copy, Clone, Debug)]
77pub enum ErrorLimitStatus {
78    /// Too many errors: requests are refused until the window resets.
79    Limited {
80        /// Milliseconds until the error-limit window resets.
81        for_millis: i64,
82    },
83    /// Requests may be made.
84    NotLimited,
85}
86
87/// What is known about the download of the spec held by an [`Esi`].
88#[derive(Clone, Debug)]
89struct SpecInfo {
90    /// The compatibility date the spec was requested with.
91    compatibility_date: String,
92    etag: Option<String>,
93    last_modified: Option<String>,
94    /// Unix time in milliseconds until which the spec needs no new request.
95    expires_at: i64,
96}
97
98/// Latest rate-limit state ESI reported for one route group.
99///
100/// ESI uses a floating-window token bucket per application/character
101/// pair and route group; see the [ESI rate limiting docs]. The values
102/// are those of the most recent response for the group.
103///
104/// [ESI rate limiting docs]: https://developers.eveonline.com/docs/services/esi/rate-limiting/
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct RateLimitStatus {
107    /// Route group, from `X-Ratelimit-Group`.
108    pub group: String,
109    /// Raw `X-Ratelimit-Limit` value, e.g. `150/15m`.
110    pub limit: String,
111    /// Tokens per window, parsed from `limit` (e.g. `150`).
112    pub max_tokens: Option<u64>,
113    /// Window length in seconds, parsed from `limit` (e.g. `900` for `15m`).
114    pub window_secs: Option<u64>,
115    /// Tokens left in the window, from `X-Ratelimit-Remaining`.
116    pub remaining: i64,
117    /// Tokens consumed by the request that returned these headers,
118    /// from `X-Ratelimit-Used`.
119    pub used: i64,
120    /// Millisecond unix timestamp of the response these values came from.
121    pub updated_at_millis: i64,
122}
123
124/// Parse an `X-Ratelimit-Limit` value such as `150/15m` into
125/// `(tokens, window_secs)`.
126fn parse_rate_limit(value: &str) -> (Option<u64>, Option<u64>) {
127    let Some((tokens, window)) = value.trim().split_once('/') else {
128        return (value.trim().parse().ok(), None);
129    };
130    let tokens = tokens.trim().parse().ok();
131    let window = window.trim();
132    let split = window
133        .find(|c: char| !c.is_ascii_digit())
134        .unwrap_or(window.len());
135    let (amount, unit) = window.split_at(split);
136    let amount: Option<u64> = amount.parse().ok();
137    let multiplier = match unit {
138        "" | "s" => Some(1),
139        "m" => Some(60),
140        "h" => Some(3600),
141        "d" => Some(86_400),
142        _ => None,
143    };
144    let window_secs = amount.zip(multiplier).map(|(a, m)| a * m);
145    (tokens, window_secs)
146}
147
148/// Which base URL to start with - the public URL for unauthenticated
149/// calls, or the authenticated URL for making calls to endpoints that
150/// require an access token.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum RequestType {
153    /// Endpoints that do not require authentication
154    Public,
155    /// Endpoints that require acting on behalf of an authenticated character
156    Authenticated,
157}
158
159/// AuthenticationInformation contains data needed to complete the requested authentication flow.
160pub struct AuthenticationInformation {
161    /// URL to call/pass to users to initiate an authentication and get an auth code from ESI.
162    pub authorization_url: String,
163    /// If the default feature "random_state" is enabled, the returned state field string will be
164    /// random; otherwise it'll be "esi_openapi_unused". The ESI docs link to
165    /// [this auth0 page](https://auth0.com/docs/secure/attack-protection/state-parameters)
166    /// to explain. You need to check the state yourself when the response from ESI is received.
167    pub state: String,
168    /// Filled if you've selected PKCE authentication for application.
169    /// You will need it to authenticate using the code received from ESI.
170    pub pkce_verifier: Option<PkceVerifier>,
171}
172
173/// Struct to interact with ESI.
174///
175/// Construct an instance of this struct using [`EsiBuilder`](./struct.EsiBuilder.html).
176///
177/// # Example
178/// ```rust,no_run
179/// use esi_openapi::prelude::EsiBuilder;
180/// // the struct must be mutable for some functionality
181/// let mut esi = EsiBuilder::new()
182///     .user_agent("some user agent")
183///     .client_id("your_client_id")
184///     .client_secret("your_client_secret")
185///     .callback_url("your_callback_url")
186///     .build()
187///     .unwrap();
188/// ```
189#[derive(Clone, Debug)]
190pub struct Esi {
191    pub(crate) compatibility_date: String,
192    pub(crate) client_id: Option<String>,
193    pub(crate) client_secret: Option<String>,
194    pub(crate) callback_url: Option<String>,
195    pub(crate) base_api_url: String,
196    pub(crate) authorize_url: String,
197    pub(crate) token_url: String,
198    pub(crate) spec_url: String,
199    pub(crate) scope: String,
200    pub(crate) application_auth: bool,
201    /// The access token from ESI, if set.
202    pub access_token: Option<String>,
203    /// The millisecond unix timestamp after which the access token expires, if present.
204    pub access_expiration: Option<i64>,
205    /// The refresh token from ESI, if set.
206    pub refresh_token: Option<String>,
207    /// HTTP client
208    pub(crate) client: Client,
209    pub(crate) spec: Option<Spec>,
210    /// How and when the spec was downloaded, if it was; see `update_spec`.
211    spec_info: Option<SpecInfo>,
212    /// Lookup tables built from `spec`.
213    index: SpecIndex,
214    error_limit_state: Arc<RwLock<Option<ErrorLimitState>>>,
215    rate_limits: Arc<RwLock<HashMap<String, RateLimitStatus>>>,
216    /// Budgets by route group and access token, used by `rate_limit_policy`.
217    limiter: Arc<RateLimiter>,
218    /// What to do when a route group has no tokens left.
219    pub(crate) rate_limit_policy: RateLimitPolicy,
220    /// Cached `GET` responses, when the cache is enabled.
221    cache: Option<Arc<RwLock<ResponseCache>>>,
222    /// Pages requested at the same time by `fetch_all_pages`.
223    pub(crate) page_concurrency: usize,
224    /// Language of the responses (`Accept-Language`), if set.
225    pub(crate) language: Option<Language>,
226    /// Tenant (`X-Tenant`), if set.
227    pub(crate) tenant: Option<String>,
228}
229
230impl Esi {
231    /// Consume the builder, creating an instance of this struct.
232    pub(crate) fn from_builder(builder: EsiBuilder) -> EsiResult<Self> {
233        let client = builder.construct_client()?;
234        let compatibility_date = builder
235            .compatibility_date
236            .unwrap_or_else(|| COMPATIBILITY_DATE_DEFAULT.to_owned());
237        let index = builder
238            .spec
239            .as_ref()
240            .map(SpecIndex::new)
241            .unwrap_or_default();
242        let e = Esi {
243            compatibility_date: compatibility_date.clone(),
244            client_id: builder.client_id,
245            client_secret: builder.client_secret,
246            callback_url: builder.callback_url,
247            base_api_url: builder.base_api_url.unwrap_or(BASE_URL.to_string()),
248            authorize_url: builder.authorize_url.unwrap_or(AUTHORIZE_URL.to_string()),
249            token_url: builder.token_url.unwrap_or(TOKEN_URL.to_string()),
250            spec_url: builder.spec_url.unwrap_or(SPEC_URL.to_string()),
251            scope: builder.scope.unwrap_or_else(|| "".to_owned()),
252            application_auth: builder.application_auth.unwrap_or(false),
253            access_token: builder.access_token,
254            access_expiration: builder.access_expiration,
255            refresh_token: builder.refresh_token,
256            client,
257            spec: builder.spec,
258            spec_info: None,
259            index,
260            error_limit_state: Arc::new(RwLock::new(None)),
261            rate_limits: Arc::new(RwLock::new(HashMap::new())),
262            limiter: Arc::new(RateLimiter::default()),
263            rate_limit_policy: builder.rate_limit_policy.unwrap_or_default(),
264            page_concurrency: builder.page_concurrency.unwrap_or(4).max(1),
265            language: builder.language,
266            tenant: builder.tenant.clone(),
267            cache: builder.cache_enabled.unwrap_or(false).then(|| {
268                Arc::new(RwLock::new(ResponseCache::with_limits(
269                    builder
270                        .cache_max_entries
271                        .unwrap_or(crate::cache::DEFAULT_MAX_ENTRIES),
272                    builder.cache_max_bytes,
273                )))
274            }),
275        };
276        Ok(e)
277    }
278
279    /// Get the OpenAPI spec from ESI and store it in this struct.
280    ///
281    /// The spec is requested with this struct's compatibility date
282    /// (`X-Compatibility-Date`), so the paths match the API version
283    /// that later requests will use.
284    ///
285    /// If you are making use of the `try_get_endpoint_for_op_id`,
286    /// then this function will be called there when needed
287    /// (which should only really be when the struct is
288    /// constructed unless the struct is kept in memory for a very
289    /// long time). When using `get_endpoint_for_op_id` however,
290    /// you are responsible for calling this function beforehand.
291    ///
292    /// # Example
293    /// ```rust,no_run
294    /// # async fn run() {
295    /// # use esi_openapi::prelude::*;
296    /// # let mut esi = EsiBuilder::new()
297    /// #     .user_agent("some user agent")
298    /// #     .client_id("your_client_id")
299    /// #     .client_secret("your_client_secret")
300    /// #     .callback_url("your_callback_url")
301    /// #     .build()
302    /// #     .unwrap();
303    /// esi.update_spec().await.unwrap();
304    /// # }
305    /// ```
306    ///
307    /// This always makes a request. If the spec was downloaded before with the
308    /// same compatibility date and the server gave it an `ETag` or
309    /// `Last-Modified`, the request is conditional (`If-None-Match` /
310    /// `If-Modified-Since`) and a `304 Not Modified` keeps the spec in memory
311    /// instead of downloading it again. To skip the request while the spec is
312    /// still fresh, use [`Esi::ensure_spec_fresh`].
313    pub async fn update_spec(&mut self) -> EsiResult<()> {
314        debug!(
315            "Updating spec with compatibility date {}",
316            self.compatibility_date
317        );
318        self.assert_not_error_limited().await?;
319        let mut request = self.client.get(&self.spec_url).header(
320            COMPATIBILITY_HEADER,
321            HeaderValue::from_str(&self.compatibility_date)?,
322        );
323        let known = self.spec_info.as_ref().filter(|info| {
324            self.spec.is_some() && info.compatibility_date == self.compatibility_date
325        });
326        if let Some(info) = known {
327            if let Some(etag) = &info.etag {
328                request = request.header(header::IF_NONE_MATCH, HeaderValue::from_str(etag)?);
329            }
330            if let Some(modified) = &info.last_modified {
331                request =
332                    request.header(header::IF_MODIFIED_SINCE, HeaderValue::from_str(modified)?);
333            }
334        }
335        let resp = request.send().await?;
336        self.process_response_headers(resp.headers()).await?;
337        if resp.status() == reqwest::StatusCode::NOT_MODIFIED && known.is_some() {
338            debug!("The spec has not changed");
339            let expires_at = Self::spec_expiry(resp.headers())?;
340            if let Some(info) = &mut self.spec_info {
341                info.expires_at = expires_at;
342            }
343            return Ok(());
344        }
345        if !resp.status().is_success() {
346            error!("Got status {} when requesting spec", resp.status());
347            return Err(Self::status_error(resp.status().as_u16(), resp.headers()));
348        }
349        let info = SpecInfo {
350            compatibility_date: self.compatibility_date.clone(),
351            etag: Self::header_text(resp.headers(), "etag"),
352            last_modified: Self::header_text(resp.headers(), "last-modified"),
353            expires_at: Self::spec_expiry(resp.headers())?,
354        };
355        let data: Spec = resp.json().await?;
356        self.index = SpecIndex::new(&data);
357        self.spec = Some(data);
358        self.spec_info = Some(info);
359        Ok(())
360    }
361
362    /// Make sure the spec is loaded and still fresh, requesting it only if it is
363    /// not: it has not been downloaded by this struct (a spec given to the
364    /// builder has no known age), it was downloaded with another compatibility
365    /// date, or its `Cache-Control: max-age` has passed. Use it before a long
366    /// run of calls instead of [`Esi::update_spec`] to avoid needless downloads.
367    pub async fn ensure_spec_fresh(&mut self) -> EsiResult<()> {
368        let now = current_time_millis()?;
369        let fresh = self.spec.is_some()
370            && self.spec_info.as_ref().is_some_and(|info| {
371                info.compatibility_date == self.compatibility_date && info.expires_at > now
372            });
373        if fresh {
374            debug!("The spec is still fresh");
375            return Ok(());
376        }
377        self.update_spec().await
378    }
379
380    /// When a spec downloaded now needs a new request: `max-age` after now.
381    fn spec_expiry(headers: &HeaderMap) -> EsiResult<i64> {
382        let max_age = ResponseCache::max_age(headers).unwrap_or(0);
383        Ok(current_time_millis()?.saturating_add(max_age.saturating_mul(1000)))
384    }
385
386    /// Ensure the user has specified all required EVE Developer App information.
387    fn check_client_info(&self) -> EsiResult<()> {
388        for (name, value) in &[
389            ("client_id", &self.client_id),
390            ("callback_url", &self.callback_url),
391        ] {
392            if value.is_none() {
393                return Err(EsiError::EmptyClientValue(name.to_string()));
394            }
395        }
396
397        if self.client_secret.is_none() {
398            if !self.application_auth {
399                return Err(EsiError::MissingAuthenticationFlowInformation);
400            }
401        } else if self.application_auth {
402            return Err(EsiError::MissingAuthenticationFlowInformation);
403        }
404
405        Ok(())
406    }
407
408    /// Generate and return the URL required for the user to grant you an auth code, as wells as
409    /// infos for future authentication request.
410    ///
411    /// You can inspect the URL returned by ESI to your web service to ensure it matches.
412    /// No checking is done by `esi-openapi`.
413    ///
414    /// # Example
415    /// ```rust,no_run
416    /// # use esi_openapi::prelude::*;
417    /// # let mut esi = EsiBuilder::new()
418    /// #     .user_agent("some user agent")
419    /// #     .client_id("your_client_id")
420    /// #     .client_secret("your_client_secret")
421    /// #     .callback_url("your_callback_url")
422    /// #     .build()
423    /// #     .unwrap();
424    /// let auth_info = esi.get_authorize_url().unwrap();
425    /// // then send your user to that URL
426    /// let url = auth_info.authorization_url;
427    /// ```
428    ///
429    /// If you opted to not include client information in
430    /// the EsiBuilder flow, then this function will return
431    /// an error instead.
432    ///
433    /// [this auth0 page]: https://auth0.com/docs/secure/attack-protection/state-parameters
434    pub fn get_authorize_url(&self) -> EsiResult<AuthenticationInformation> {
435        self.check_client_info()?;
436        #[cfg(feature = "random_state")]
437        let state = rand::rng()
438            .sample_iter(&Alphanumeric)
439            .take(10)
440            .map(char::from)
441            .collect();
442        #[cfg(not(feature = "random_state"))]
443        let state = "esi_openapi_unused".to_string();
444        let mut url = format!(
445            "{}?response_type=code&redirect_uri={}&client_id={}&scope={}&state={state}",
446            self.authorize_url,
447            self.callback_url.as_ref().unwrap(),
448            self.client_id.as_ref().unwrap(),
449            self.scope
450        );
451        let mut pkce_verifier = None;
452        // PKCE can be theoretically combined with client secret, but not sure if ESI supports it
453        if self.client_secret.is_none() && self.application_auth {
454            let pkce = pkce::generate()?;
455            pkce_verifier = Some(pkce.verifier);
456            url = format!(
457                "{}&code_challenge={}&code_challenge_method=S256",
458                url, pkce.challenge
459            )
460        }
461        Ok(AuthenticationInformation {
462            authorization_url: url,
463            state,
464            pkce_verifier,
465        })
466    }
467
468    fn get_auth_headers(&self) -> EsiResult<HeaderMap> {
469        self.check_client_info()?;
470        let mut map = HeaderMap::new();
471        if let Some(ref secret) = self.client_secret {
472            let value = base64
473                .encode(format!("{}:{secret}", self.client_id.as_ref().unwrap()))
474                .replace(['\n', ' '], "");
475            map.insert(
476                header::AUTHORIZATION,
477                HeaderValue::from_str(&format!("Basic {value}"))?,
478            );
479        }
480        map.insert(
481            header::HOST,
482            HeaderValue::from_static("login.eveonline.com"),
483        );
484        Ok(map)
485    }
486
487    /// Authenticate with ESI, exchanging a code from the authorize flow
488    /// for an access token that is used to make authenticated calls to ESI.
489    ///
490    /// Note that this is one of the functions that requires the struct be
491    /// mutable, as the struct mutates to include the resulting access token.
492    ///
493    /// If the "validate_jwt" feature is enabled (by default), then the access
494    /// token's claims will be returned. If the feature is not enabled, then
495    /// the returned value will be `None`.
496    ///
497    /// # Example (client secret)
498    /// ```rust,no_run
499    /// # async fn run() {
500    /// # use esi_openapi::prelude::*;
501    /// # let mut esi = EsiBuilder::new()
502    /// #     .user_agent("some user agent")
503    /// #     .client_id("your_client_id")
504    /// #     .client_secret("your_client_secret")
505    /// #     .callback_url("your_callback_url")
506    /// #     .build()
507    /// #     .unwrap();
508    /// let claims = esi.authenticate("abcdef...", None).await.unwrap();
509    /// # }
510    /// ```
511    ///
512    /// # Example (PKCE/Application authentication)
513    /// ```rust,no_run
514    /// # use esi_openapi::prelude::*;
515    ///  async fn run() {
516    /// # let mut esi = EsiBuilder::new()
517    /// #     .user_agent("some user agent")
518    /// #     .client_id("your_client_id")
519    /// #     .callback_url("your_callback_url")
520    /// #     .enable_application_authentication(true)
521    /// #     .build()
522    /// #     .unwrap();
523    /// # let auth_infos = esi.get_authorize_url().unwrap();
524    /// # let claims = esi.authenticate("abcdef...", auth_infos.pkce_verifier).await.unwrap();
525    /// # }
526    /// ```
527    pub async fn authenticate(
528        &mut self,
529        code: &str,
530        pkce_verifier: Option<PkceVerifier>,
531    ) -> EsiResult<Option<TokenClaims>> {
532        debug!("Authenticating with code {code}");
533        self.assert_not_error_limited().await?;
534        let mut body = HashMap::from([("grant_type", "authorization_code"), ("code", code)]);
535        if self.application_auth {
536            let option = self.client_id.as_ref();
537            body.insert("client_id", option.unwrap());
538            body.insert("code_verifier", pkce_verifier.as_ref().unwrap());
539        }
540
541        let resp = self
542            .client
543            .post(&self.token_url)
544            .headers(self.get_auth_headers()?)
545            .form(&body)
546            .send()
547            .await?;
548        if resp.status() != 200 {
549            warn!(
550                "Got status {} when making call to authenticate",
551                resp.status()
552            );
553            return Err(EsiError::InvalidStatusCode(resp.status().as_u16()));
554        }
555        self.process_error_limit_headers(resp.headers()).await?;
556        let data: AuthenticateResponse = resp.json().await?;
557        #[allow(unused_variables)]
558        let claim_data: Option<TokenClaims> = None;
559        #[cfg(feature = "validate_jwt")]
560        let claim_data = Some(
561            crate::jwt_util::validate_jwt(
562                &self.client,
563                &data.access_token,
564                self.client_id.as_ref().unwrap(),
565            )
566            .await?,
567        );
568        self.access_token = Some(data.access_token);
569        // the response's "expires_in" field is seconds but need millis
570        self.access_expiration = Some((data.expires_in as i64 * 1_000) + current_time_millis()?);
571        self.refresh_token = data.refresh_token;
572        Ok(claim_data)
573    }
574
575    /// Authenticate via a previously-fetched refresh token.
576    ///
577    /// The functionality of a refresh token allows re-authenticating this struct
578    /// instance without prompting the user to log into EVE SSO again. When the user
579    /// is authenticated in that manner, a refresh token is returned and available
580    /// via the `refresh_token` struct field. Store this securely should you wish
581    /// to later make authenticate calls for that user.
582    ///
583    /// # Example
584    /// ```rust,no_run
585    /// # async fn run() {
586    /// # use esi_openapi::prelude::*;
587    /// # let mut esi = EsiBuilder::new()
588    /// #     .user_agent("some user agent")
589    /// #     .client_id("your_client_id")
590    /// #     .client_secret("your_client_secret")
591    /// #     .callback_url("your_callback_url")
592    /// #     .build()
593    /// #     .unwrap();
594    /// esi.use_refresh_token("abcdef...").await.unwrap();
595    /// # }
596    /// ```
597    pub async fn use_refresh_token(&mut self, refresh_token: &str) -> EsiResult<()> {
598        self.refresh_access_token(Some(refresh_token)).await?;
599        Ok(())
600    }
601
602    /// Authenticate via a refresh token given as input, or using the internal refresh_token if it's available.
603    ///
604    /// The functionality of a refresh token allows re-authenticating this struct
605    /// instance without prompting the user to log into EVE SSO again. When the user
606    /// is authenticated in that manner, a refresh token is returned and available
607    /// via the `refresh_token` struct field. Store this securely should you wish
608    /// to later make authenticate calls for that user.
609    ///
610    /// # Example with internal token
611    /// ```rust,no_run
612    /// # async fn run() {
613    /// # use esi_openapi::prelude::*;
614    /// # let mut esi = EsiBuilder::new()
615    /// #     .user_agent("some user agent")
616    /// #     .refresh_token(Some("MyRefreshToken"))
617    /// #     .build()
618    /// #     .unwrap();
619    /// esi.refresh_access_token(None).await.unwrap();
620    /// # }
621    /// ```
622    /// # Example with input token
623    /// ```rust,no_run
624    /// # async fn run() {
625    /// # use esi_openapi::prelude::*;
626    /// # let mut esi = EsiBuilder::new()
627    /// #     .user_agent("some user agent")
628    /// #     .build()
629    /// #     .unwrap();
630    /// esi.refresh_access_token(Some("MyRefreshToken")).await.unwrap();
631    /// # }
632    /// ```
633    pub async fn refresh_access_token(&mut self, refresh_token: Option<&str>) -> EsiResult<()> {
634        self.assert_not_error_limited().await?;
635        let token = if let Some(token) = refresh_token {
636            token.to_string()
637        } else if let Some(token) = self.refresh_token.clone() {
638            token
639        } else {
640            return Err(EsiError::NoRefreshTokenAvailable);
641        };
642
643        debug!("Authenticating with refresh token");
644        let mut body = HashMap::from([("grant_type", "refresh_token"), ("refresh_token", &token)]);
645        if self.application_auth {
646            let option = self.client_id.as_ref();
647            body.insert("client_id", option.unwrap());
648        }
649        let resp = self
650            .client
651            .post(&self.token_url)
652            .headers(self.get_auth_headers()?)
653            .form(&body)
654            .send()
655            .await?;
656        self.process_error_limit_headers(resp.headers()).await?;
657        if resp.status() != 200 {
658            warn!(
659                "Got status {} when making call to authenticate via a refresh token",
660                resp.status()
661            );
662            return Err(EsiError::InvalidStatusCode(resp.status().as_u16()));
663        }
664        let data: RefreshTokenAuthenticateResponse = resp.json().await?;
665        self.access_token = Some(data.access_token);
666        // the response's "expires_in" field is seconds, need millis
667        self.access_expiration = Some((data.expires_in as i64 * 1_000) + current_time_millis()?);
668        self.refresh_token = Some(data.refresh_token);
669        Ok(())
670    }
671
672    /// Make a request to ESI.
673    ///
674    /// This is mainly used as the underlying function for this
675    /// library when making calls to ESI; the other functions that
676    /// you should primarily be using contain more functionality,
677    /// including matching endpoint with deserialization struct,
678    /// evaluating & replacing URL parameters, etc.
679    ///
680    /// In the event that there is not a wrapper function for the
681    /// endpoint that you want to use, you can use this function
682    /// to make an API call without waiting for the library to
683    /// be updated.
684    ///
685    /// # Example
686    /// ```rust,no_run
687    /// # async fn run() {
688    /// # use serde::Deserialize;
689    /// # use esi_openapi::prelude::*;
690    /// # let mut esi = EsiBuilder::new()
691    /// #     .user_agent("some user agent")
692    /// #     .client_id("your_client_id")
693    /// #     .client_secret("your_client_secret")
694    /// #     .callback_url("your_callback_url")
695    /// #     .build()
696    /// #     .unwrap();
697    /// #[derive(Deserialize)]
698    /// struct ReturnedData {}
699    /// let data: ReturnedData = esi.query("GET", RequestType::Public, "abc", None, None).await.unwrap();
700    /// # }
701    /// ```
702    pub async fn query<T: DeserializeOwned>(
703        &self,
704        method: &str,
705        request_type: RequestType,
706        endpoint: &str,
707        query: Option<&[(&str, &str)]>,
708        body: Option<&str>,
709    ) -> EsiResult<T> {
710        let (text, _) = self
711            .send_request(method, request_type, endpoint, query, body)
712            .await?;
713        Self::parse_body(&text)
714    }
715
716    /// Like [`Esi::query`], but also returns the total number of pages
717    /// reported by the `X-Pages` response header, if present.
718    ///
719    /// # Example
720    /// ```rust,no_run
721    /// # async fn run() {
722    /// # use esi_openapi::prelude::*;
723    /// # let esi = EsiBuilder::new()
724    /// #     .user_agent("some user agent")
725    /// #     .client_id("your_client_id")
726    /// #     .client_secret("your_client_secret")
727    /// #     .callback_url("your_callback_url")
728    /// #     .build()
729    /// #     .unwrap();
730    /// let (data, pages): (Vec<serde_json::Value>, Option<i64>) = esi
731    ///     .query_with_pages("GET", RequestType::Public, "some/path", Some(&[("page", "2")]))
732    ///     .await
733    ///     .unwrap();
734    /// # }
735    /// ```
736    pub async fn query_with_pages<T: DeserializeOwned>(
737        &self,
738        method: &str,
739        request_type: RequestType,
740        endpoint: &str,
741        query: Option<&[(&str, &str)]>,
742    ) -> EsiResult<(T, Option<i64>)> {
743        let (text, headers) = self
744            .send_request(method, request_type, endpoint, query, None)
745            .await?;
746        Ok((Self::parse_body(&text)?, Self::pages_header(&headers)))
747    }
748
749    /// Fetch every page of an endpoint paginated with the `page` query
750    /// parameter and return the items of all pages, in order.
751    ///
752    /// The first page is requested with `page=1`; the `X-Pages` header of its
753    /// response says how many pages follow, and those are requested
754    /// concurrently, [`EsiBuilder::page_concurrency`] at a time. Pass the other
755    /// query parameters of the endpoint in `query`, without `page`. `max_pages`
756    /// limits how many pages are fetched (`None` for all of them).
757    pub async fn fetch_all_pages<T: DeserializeOwned>(
758        &self,
759        request_type: RequestType,
760        endpoint: &str,
761        query: Option<&[(&str, &str)]>,
762        max_pages: Option<i64>,
763    ) -> EsiResult<Vec<T>> {
764        let (mut items, total) = self
765            .fetch_page::<T>(request_type, endpoint, query, 1)
766            .await?;
767        let last = total.unwrap_or(1).min(max_pages.unwrap_or(i64::MAX));
768        let step = i64::try_from(self.page_concurrency).unwrap_or(1);
769        let mut next: i64 = 2;
770        while next <= last {
771            let end = (next + step - 1).min(last);
772            let batches = futures_util::future::try_join_all(
773                (next..=end).map(|page| self.fetch_page::<T>(request_type, endpoint, query, page)),
774            )
775            .await?;
776            for (mut batch, _) in batches {
777                items.append(&mut batch);
778            }
779            next = end + 1;
780        }
781        Ok(items)
782    }
783
784    /// Send a `POST` whose body is a JSON array and return the answers joined
785    /// in order, splitting `items` into requests of at most `chunk_size` items.
786    ///
787    /// Use it for endpoints that cap the length of the array (such as
788    /// `characters/affiliation`, at 1000 ids). The chunks are sent
789    /// concurrently, [`EsiBuilder::page_concurrency`] at a time. The answer to
790    /// each chunk must be an array; an empty `items` sends no request and
791    /// returns an empty list. Duplicates are not removed, and the spec asks for
792    /// unique items on most of these endpoints.
793    pub async fn post_chunked<B: Serialize, T: DeserializeOwned>(
794        &self,
795        request_type: RequestType,
796        endpoint: &str,
797        items: &[B],
798        chunk_size: usize,
799    ) -> EsiResult<Vec<T>> {
800        let chunks: Vec<&[B]> = items.chunks(chunk_size.max(1)).collect();
801        let mut results: Vec<T> = Vec::new();
802        for group in chunks.chunks(self.page_concurrency) {
803            let answers = futures_util::future::try_join_all(
804                group
805                    .iter()
806                    .map(|chunk| self.post_chunk::<B, T>(request_type, endpoint, chunk)),
807            )
808            .await?;
809            for mut answer in answers {
810                results.append(&mut answer);
811            }
812        }
813        Ok(results)
814    }
815
816    /// Send one chunk of a `POST` with an array body.
817    async fn post_chunk<B: Serialize, T: DeserializeOwned>(
818        &self,
819        request_type: RequestType,
820        endpoint: &str,
821        chunk: &[B],
822    ) -> EsiResult<Vec<T>> {
823        let body = serde_json::to_string(chunk)?;
824        self.query("POST", request_type, endpoint, None, Some(&body))
825            .await
826    }
827
828    /// Request one page of a `page`-paginated endpoint.
829    async fn fetch_page<T: DeserializeOwned>(
830        &self,
831        request_type: RequestType,
832        endpoint: &str,
833        query: Option<&[(&str, &str)]>,
834        page: i64,
835    ) -> EsiResult<(Vec<T>, Option<i64>)> {
836        let page_text = page.to_string();
837        let mut params: Vec<(&str, &str)> = query.unwrap_or(&[]).to_vec();
838        params.push(("page", page_text.as_str()));
839        self.query_with_pages("GET", request_type, endpoint, Some(&params))
840            .await
841    }
842
843    /// Fetch every page of an endpoint paginated with cursors (`x-pagination:
844    /// cursor`) and return the items of all pages, in order.
845    ///
846    /// `items_key` is the name of the array in the response that holds the
847    /// records, such as `"projects"` or `"listings"`. The walk follows the
848    /// `cursor.after` value of each response until a page has no records or no
849    /// further cursor. Pass the other query parameters of the endpoint (such as
850    /// `limit`) in `query`, without `after` or `before`. Unless `query` has a
851    /// `limit`, the maximum the spec allows (100, against a default of 10) is
852    /// requested to need fewer calls. `max_pages` limits how many pages are
853    /// fetched (`None` for all of them).
854    pub async fn fetch_all_cursor<T: DeserializeOwned>(
855        &self,
856        request_type: RequestType,
857        endpoint: &str,
858        query: Option<&[(&str, &str)]>,
859        items_key: &str,
860        max_pages: Option<i64>,
861    ) -> EsiResult<Vec<T>> {
862        let mut items: Vec<T> = Vec::new();
863        let mut after = String::from("0");
864        let mut fetched: i64 = 0;
865        loop {
866            let mut params: Vec<(&str, &str)> = query.unwrap_or(&[]).to_vec();
867            if !params.iter().any(|(key, _)| *key == "limit") {
868                params.push(("limit", CURSOR_PAGE_LIMIT));
869            }
870            params.push(("after", after.as_str()));
871            let (text, _) = self
872                .send_request("GET", request_type, endpoint, Some(&params), None)
873                .await?;
874            let page = CursorPage::<T>::parse(&text, items_key)?;
875            fetched += 1;
876            let empty = page.records.is_empty();
877            let next = page.next;
878            items.extend(page.records);
879            match next {
880                Some(next)
881                    if !empty && next != after && fetched < max_pages.unwrap_or(i64::MAX) =>
882                {
883                    after = next;
884                }
885                _ => return Ok(items),
886            }
887        }
888    }
889
890    /// The total number of pages from the `X-Pages` header.
891    fn pages_header(headers: &HeaderMap) -> Option<i64> {
892        headers
893            .get("x-pages")
894            .and_then(|v| v.to_str().ok())
895            .and_then(|v| v.trim().parse().ok())
896    }
897
898    /// Read a response body as JSON. A body that is empty (such as `204 No
899    /// Content`) is read as JSON `null`, so `()` and `Option<_>` return types
900    /// work for it.
901    fn parse_body<T: DeserializeOwned>(text: &str) -> EsiResult<T> {
902        let text = if text.trim().is_empty() { "null" } else { text };
903        Ok(serde_json::from_str(text)?)
904    }
905
906    /// Send a request and return the body text and the response headers.
907    async fn send_request(
908        &self,
909        method: &str,
910        request_type: RequestType,
911        endpoint: &str,
912        query: Option<&[(&str, &str)]>,
913        body: Option<&str>,
914    ) -> EsiResult<(String, HeaderMap)> {
915        debug!("Making {request_type:?} {method} request to {endpoint} with query: {query:?}");
916        let cache_key = self.cache_key(method, &request_type, endpoint, query);
917        let mut stored: Option<CacheEntry> = None;
918        if let Some(key) = &cache_key {
919            if let Some(entry) = self.cache_lookup(key).await {
920                if entry.expires_at > current_time_millis()? {
921                    debug!("Serving {endpoint} from the cache");
922                    return Ok((entry.body, entry.headers));
923                }
924                stored = Some(entry);
925            }
926        }
927        self.assert_not_error_limited().await?;
928        self.check_authentication(&request_type)?;
929        let bucket = self.bucket_key(method, &request_type, endpoint);
930        let _permit = match &bucket {
931            Some(key) => Some(self.acquire_permit(key).await?),
932            None => None,
933        };
934        let mut headers = self.request_headers(&request_type)?;
935        if let Some(entry) = &stored {
936            if let Some(etag) = &entry.etag {
937                headers.insert(header::IF_NONE_MATCH, HeaderValue::from_str(etag)?);
938            }
939            if let Some(modified) = &entry.last_modified {
940                headers.insert(header::IF_MODIFIED_SINCE, HeaderValue::from_str(modified)?);
941            }
942        }
943        let url = format!("{}{endpoint}", self.base_api_url);
944        let mut req_builder = self
945            .client
946            .request(Method::from_str(method)?, &url)
947            .headers(headers)
948            .query(query.unwrap_or(&[]));
949        req_builder = match body {
950            Some(b) => req_builder.body(b.to_owned()),
951            None => req_builder,
952        };
953        let req = req_builder.build()?;
954        let resp = self.client.execute(req).await?;
955        let rate_limit = self.process_response_headers(resp.headers()).await?;
956        if let Some(key) = &bucket {
957            self.record_budget(key, rate_limit.as_ref(), resp.status(), resp.headers())?;
958        }
959        if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
960            if let (Some(key), Some(entry)) = (&cache_key, stored) {
961                debug!("{endpoint} not modified; reusing the cached body");
962                let expires_at = self.cache_expiry(endpoint, resp.headers())?;
963                self.cache_refresh(key, expires_at).await;
964                return Ok((entry.body, entry.headers));
965            }
966        }
967        if !resp.status().is_success() {
968            let status = resp.status().as_u16();
969            if matches!(status, 404 | 410) {
970                if let Some(ttl) = self.index.tombstone_ttl(endpoint) {
971                    return Err(EsiError::Gone {
972                        status,
973                        tombstone_ttl_secs: ttl,
974                    });
975                }
976            }
977            return Err(Self::status_error(status, resp.headers()));
978        }
979        let headers = resp.headers().clone();
980        let text = resp.text().await?;
981        if let Some(key) = cache_key {
982            let entry = CacheEntry {
983                etag: Self::header_text(&headers, "etag"),
984                last_modified: Self::header_text(&headers, "last-modified"),
985                body: text.clone(),
986                headers: headers.clone(),
987                expires_at: self.cache_expiry(endpoint, &headers)?,
988                last_used: 0,
989            };
990            self.cache_store(key, entry).await?;
991        }
992        Ok((text, headers))
993    }
994
995    /// The cache key of a request, if the cache is enabled and the request is a `GET`.
996    fn cache_key(
997        &self,
998        method: &str,
999        request_type: &RequestType,
1000        endpoint: &str,
1001        query: Option<&[(&str, &str)]>,
1002    ) -> Option<String> {
1003        self.cache.as_ref()?;
1004        if !method.eq_ignore_ascii_case("GET") {
1005            return None;
1006        }
1007        let token = match request_type {
1008            RequestType::Authenticated => self.access_token.as_deref(),
1009            RequestType::Public => None,
1010        };
1011        let url = format!("{}{endpoint}", self.base_api_url);
1012        let variant = format!(
1013            "{}|{}",
1014            self.language.map_or("", |l| l.as_str()),
1015            self.tenant.as_deref().unwrap_or("")
1016        );
1017        Some(ResponseCache::key(
1018            token,
1019            &variant,
1020            &url,
1021            query.unwrap_or(&[]),
1022        ))
1023    }
1024
1025    /// The cached entry for a key, marking it as recently used.
1026    async fn cache_lookup(&self, key: &str) -> Option<CacheEntry> {
1027        self.cache.as_ref()?.write().await.lookup(key)
1028    }
1029
1030    async fn cache_store(&self, key: String, entry: CacheEntry) -> EsiResult<()> {
1031        if let Some(cache) = &self.cache {
1032            cache
1033                .write()
1034                .await
1035                .insert(key, entry, current_time_millis()?);
1036        }
1037        Ok(())
1038    }
1039
1040    async fn cache_refresh(&self, key: &str, expires_at: i64) {
1041        if let Some(cache) = &self.cache {
1042            cache.write().await.refresh(key, expires_at);
1043        }
1044    }
1045
1046    /// When a response stored now stops being served without revalidation: the
1047    /// `x-client-cache-ttl` of the operation in the spec, else the `max-age`
1048    /// of the response, else immediately.
1049    fn cache_expiry(&self, endpoint: &str, headers: &HeaderMap) -> EsiResult<i64> {
1050        let ttl = self
1051            .index
1052            .client_cache_ttl(endpoint)
1053            .or_else(|| ResponseCache::max_age(headers))
1054            .unwrap_or(0);
1055        Ok(current_time_millis()? + ttl * 1000)
1056    }
1057
1058    fn header_text(headers: &HeaderMap, name: &str) -> Option<String> {
1059        headers
1060            .get(name)
1061            .and_then(|v| v.to_str().ok())
1062            .map(str::to_owned)
1063    }
1064
1065    /// The number of responses held by the cache (0 when it is disabled).
1066    pub async fn cache_len(&self) -> usize {
1067        match &self.cache {
1068            Some(cache) => cache.read().await.len(),
1069            None => 0,
1070        }
1071    }
1072
1073    /// The approximate size in bytes of the responses held by the cache (0 when
1074    /// it is disabled). See [`EsiBuilder::cache_max_bytes`].
1075    pub async fn cache_bytes(&self) -> usize {
1076        match &self.cache {
1077            Some(cache) => cache.read().await.bytes(),
1078            None => 0,
1079        }
1080    }
1081
1082    /// For an authenticated request, fails unless there is a valid, unexpired
1083    /// access token.
1084    fn check_authentication(&self, request_type: &RequestType) -> EsiResult<()> {
1085        if *request_type != RequestType::Authenticated {
1086            return Ok(());
1087        }
1088        if self.access_token.is_none() {
1089            return Err(EsiError::MissingAuthentication);
1090        }
1091        if self.access_expiration.unwrap() < current_time_millis()? {
1092            return Err(EsiError::AccessTokenExpired);
1093        }
1094        Ok(())
1095    }
1096
1097    /// The per-request headers: the authorization header, if authenticated,
1098    /// and the compatibility date.
1099    fn request_headers(&self, request_type: &RequestType) -> EsiResult<HeaderMap> {
1100        let mut map = HeaderMap::new();
1101        // The 'user-agent' and 'content-type' headers are set in the default headers
1102        // from the builder, so all that's required here is to set the authorization
1103        // header, if present, and the compatibility date.
1104        if *request_type == RequestType::Authenticated {
1105            if let Some(at) = &self.access_token {
1106                map.insert(
1107                    header::AUTHORIZATION,
1108                    HeaderValue::from_str(&format!("Bearer {at}"))?,
1109                );
1110            }
1111        }
1112        map.insert(
1113            COMPATIBILITY_HEADER,
1114            HeaderValue::from_str(&self.compatibility_date)?,
1115        );
1116        if let Some(language) = self.language {
1117            map.insert(
1118                header::ACCEPT_LANGUAGE,
1119                HeaderValue::from_static(language.as_str()),
1120            );
1121        }
1122        if let Some(tenant) = &self.tenant {
1123            map.insert(TENANT_HEADER, HeaderValue::from_str(tenant)?);
1124        }
1125        Ok(map)
1126    }
1127
1128    /// Resolve an `operationId` to a URL path utilizing the OpenAPI spec.
1129    ///
1130    /// Operation IDs are those of the ESI OpenAPI spec, e.g.
1131    /// `GetMarketsRegionIdOrders`. rfesi's legacy snake_case IDs
1132    /// (e.g. `get_markets_region_id_orders`) are still accepted with a
1133    /// deprecation warning until 0.2.0.
1134    ///
1135    /// If the spec has not yet been retrieved when calling this function,
1136    /// an API call will be made to ESI to fetch that data (thus the
1137    /// async signature of this function). If you don't need that help (by
1138    /// explicitly making a call to `update_spec` prior) then you can use
1139    /// the `get_endpoint_for_op_id` function, which is synchronous.
1140    ///
1141    /// Note that when making use of this function along with `query`, you
1142    /// are responsible for resolving any/all URL parameters that the endpoint
1143    /// may contain.
1144    ///
1145    /// # Example
1146    /// ```rust,no_run
1147    /// # async fn run() {
1148    /// # use esi_openapi::prelude::*;
1149    /// # let mut esi = EsiBuilder::new()
1150    /// #     .user_agent("some user agent")
1151    /// #     .client_id("your_client_id")
1152    /// #     .client_secret("your_client_secret")
1153    /// #     .callback_url("your_callback_url")
1154    /// #     .build()
1155    /// #     .unwrap();
1156    /// let endpoint = esi
1157    ///     .try_get_endpoint_for_op_id("GetAlliancesAllianceIdContactsLabels")
1158    ///     .await
1159    ///     .unwrap();
1160    /// # }
1161    /// ```
1162    pub async fn try_get_endpoint_for_op_id(&mut self, op_id: &str) -> EsiResult<String> {
1163        if self.spec.is_none() {
1164            debug!("Spec is `None`; must fetch before looking up op_id");
1165            self.update_spec().await?;
1166        }
1167        self.get_endpoint_for_op_id(op_id)
1168    }
1169
1170    /// Resolve an `operationId` to a URL path utilizing the OpenAPI spec.
1171    ///
1172    /// Operation IDs are those of the ESI OpenAPI spec, e.g.
1173    /// `GetMarketsRegionIdOrders`. rfesi's legacy snake_case IDs
1174    /// (e.g. `get_markets_region_id_orders`) are still accepted with a
1175    /// deprecation warning until 0.2.0.
1176    ///
1177    /// If the spec has not yet been retrieved when calling this function,
1178    /// this function will return an error.
1179    ///
1180    /// Note that when making use of this function along with `query`, you
1181    /// are responsible for resolving any/all URL parameters that the endpoint
1182    /// may contain.
1183    ///
1184    /// # Example
1185    /// ```rust,no_run
1186    /// # use esi_openapi::prelude::*;
1187    /// # let mut esi = EsiBuilder::new()
1188    /// #     .user_agent("some user agent")
1189    /// #     .client_id("your_client_id")
1190    /// #     .client_secret("your_client_secret")
1191    /// #     .callback_url("your_callback_url")
1192    /// #     .build()
1193    /// #     .unwrap();
1194    /// let endpoint = esi.get_endpoint_for_op_id("GetAlliancesAllianceIdContactsLabels").unwrap();
1195    /// ```
1196    pub fn get_endpoint_for_op_id(&self, op_id: &str) -> EsiResult<String> {
1197        if self.spec.is_none() {
1198            return Err(EsiError::EmptySpec);
1199        }
1200        if let Some(path) = self.index.path(op_id) {
1201            return Ok(path.to_owned());
1202        }
1203        if let Some(new_id) = legacy::openapi_id_for(op_id) {
1204            warn!(
1205                "operationId '{op_id}' is a deprecated Swagger ID; use '{new_id}' instead (legacy IDs will be removed in 0.2.0)"
1206            );
1207            if let Some(path) = self.index.path(new_id) {
1208                return Ok(path.to_owned());
1209            }
1210        }
1211        Err(EsiError::UnknownOperationID(op_id.to_owned()))
1212    }
1213
1214    /// The operation's metadata from the spec.
1215    fn spec_operation(&self, op_id: &str) -> EsiResult<&crate::spec::SpecPathMethod> {
1216        let spec = self.spec.as_ref().ok_or(EsiError::EmptySpec)?;
1217        self.index
1218            .operation(spec, op_id)
1219            .ok_or_else(|| EsiError::UnknownOperationID(op_id.to_owned()))
1220    }
1221
1222    /// The OAuth2 scopes an operation needs, from the spec. Public operations need none.
1223    pub fn required_scopes(&self, op_id: &str) -> EsiResult<Vec<String>> {
1224        Ok(self.spec_operation(op_id)?.scopes())
1225    }
1226
1227    /// The scopes an operation needs that are not in `granted`, a space-separated
1228    /// scope list such as the `scope` value of a token. Use it to fail early instead
1229    /// of making a request ESI will answer with `401`.
1230    pub fn missing_scopes(&self, op_id: &str, granted: &str) -> EsiResult<Vec<String>> {
1231        let granted: Vec<&str> = granted.split_whitespace().collect();
1232        Ok(self
1233            .required_scopes(op_id)?
1234            .into_iter()
1235            .filter(|scope| !granted.contains(&scope.as_str()))
1236            .collect())
1237    }
1238
1239    /// The corporation roles of which the character needs at least one for an
1240    /// operation (empty when it needs none).
1241    pub fn required_roles(&self, op_id: &str) -> EsiResult<Vec<String>> {
1242        Ok(self.spec_operation(op_id)?.required_roles.clone())
1243    }
1244
1245    /// The rate limit each route group declares in the spec, keyed by group.
1246    ///
1247    /// These are the budgets before any response arrives; [`Esi::rate_limit_status`]
1248    /// has the live values once ESI has answered for a group.
1249    pub fn declared_rate_limits(&self) -> EsiResult<HashMap<String, crate::spec::SpecRateLimit>> {
1250        let spec = self.spec.as_ref().ok_or(EsiError::EmptySpec)?;
1251        Ok(spec.rate_limit_groups())
1252    }
1253
1254    /// Build the error for a non-success response status.
1255    fn status_error(status: u16, headers: &HeaderMap) -> EsiError {
1256        if status == 429 {
1257            let header_str = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
1258            let group = header_str(RATE_LIMIT_GROUP_HEADER).map(str::to_owned);
1259            let retry_after_secs = Self::retry_after_secs(headers);
1260            warn!("Rate limited by ESI (group {group:?}); retry after {retry_after_secs:?}s");
1261            return EsiError::RateLimited {
1262                group,
1263                retry_after_secs,
1264            };
1265        }
1266        EsiError::InvalidStatusCode(status)
1267    }
1268
1269    /// Record the error-limit and rate-limit headers of a response.
1270    ///
1271    /// Returns the rate-limit status the response reported, if it has one.
1272    async fn process_response_headers(
1273        &self,
1274        headers: &HeaderMap,
1275    ) -> Result<Option<RateLimitStatus>, EsiError> {
1276        self.process_error_limit_headers(headers).await?;
1277        self.process_rate_limit_headers(headers).await
1278    }
1279
1280    async fn process_rate_limit_headers(
1281        &self,
1282        headers: &HeaderMap,
1283    ) -> Result<Option<RateLimitStatus>, EsiError> {
1284        let Some(group) = headers.get(RATE_LIMIT_GROUP_HEADER) else {
1285            return Ok(None);
1286        };
1287        let group = group.to_str()?.to_owned();
1288        let limit = match headers.get(RATE_LIMIT_LIMIT_HEADER) {
1289            Some(v) => v.to_str()?.to_owned(),
1290            None => String::new(),
1291        };
1292        let parse_i64 = |name: &str| -> Result<i64, EsiError> {
1293            match headers.get(name) {
1294                Some(v) => v
1295                    .to_str()?
1296                    .trim()
1297                    .parse::<i64>()
1298                    .map_err(|e| EsiError::HeaderParseError(name.into(), e)),
1299                None => Ok(0),
1300            }
1301        };
1302        let remaining = parse_i64(RATE_LIMIT_REMAINING_HEADER)?;
1303        let used = parse_i64(RATE_LIMIT_USED_HEADER)?;
1304        let (max_tokens, window_secs) = parse_rate_limit(&limit);
1305        let status = RateLimitStatus {
1306            group: group.clone(),
1307            limit,
1308            max_tokens,
1309            window_secs,
1310            remaining,
1311            used,
1312            updated_at_millis: current_time_millis()?,
1313        };
1314        debug!("Rate limit status: {status:?}");
1315        self.rate_limits.write().await.insert(group, status.clone());
1316        Ok(Some(status))
1317    }
1318
1319    /// The key of the budget a request spends from, when throttling is on and the
1320    /// spec says which route group the operation belongs to.
1321    fn bucket_key(
1322        &self,
1323        method: &str,
1324        request_type: &RequestType,
1325        endpoint: &str,
1326    ) -> Option<String> {
1327        if self.rate_limit_policy == RateLimitPolicy::Off {
1328            return None;
1329        }
1330        let group = self.index.rate_limit_group(method, endpoint)?;
1331        let token = match request_type {
1332            RequestType::Authenticated => self.access_token.as_deref(),
1333            RequestType::Public => None,
1334        };
1335        Some(RateLimiter::key(group, token))
1336    }
1337
1338    /// Reserve budget for a request, sleeping or failing as the policy says when
1339    /// it does not fit.
1340    async fn acquire_permit(&self, key: &str) -> EsiResult<Permit> {
1341        let started = current_time_millis()?;
1342        loop {
1343            let now = current_time_millis()?;
1344            let Acquire::Wait(wait_ms) = self.limiter.try_acquire(key, now) else {
1345                return Ok(Permit::new(Arc::clone(&self.limiter), key));
1346            };
1347            let allowed_ms = match self.rate_limit_policy {
1348                RateLimitPolicy::Wait { max_wait } => {
1349                    i64::try_from(max_wait.as_millis()).unwrap_or(i64::MAX)
1350                }
1351                RateLimitPolicy::Off | RateLimitPolicy::Fail => 0,
1352            };
1353            if (now - started).saturating_add(wait_ms) > allowed_ms {
1354                let group = RateLimiter::group_of(key).to_owned();
1355                warn!("Not sending a request: group {group} has no tokens for {wait_ms}ms");
1356                return Err(EsiError::RateLimited {
1357                    group: Some(group),
1358                    retry_after_secs: Some(u64::try_from((wait_ms + 999) / 1000).unwrap_or(0)),
1359                });
1360            }
1361            debug!("Waiting {wait_ms}ms for rate-limit tokens of {key}");
1362            tokio::time::sleep(std::time::Duration::from_millis(
1363                u64::try_from(wait_ms).unwrap_or(0),
1364            ))
1365            .await;
1366        }
1367    }
1368
1369    /// Feed a response to the budget of its route group.
1370    fn record_budget(
1371        &self,
1372        key: &str,
1373        status: Option<&RateLimitStatus>,
1374        http_status: reqwest::StatusCode,
1375        headers: &HeaderMap,
1376    ) -> EsiResult<()> {
1377        let now = current_time_millis()?;
1378        if let Some(status) = status {
1379            let window_ms = status
1380                .window_secs
1381                .and_then(|secs| i64::try_from(secs).ok())
1382                .map_or(0, |secs| secs.saturating_mul(1000));
1383            self.limiter.record(
1384                key,
1385                status.remaining,
1386                status.used,
1387                window_ms,
1388                http_status.is_success(),
1389                now,
1390            );
1391        }
1392        if http_status.as_u16() == 429 {
1393            if let Some(secs) = Self::retry_after_secs(headers) {
1394                let secs = i64::try_from(secs).unwrap_or(0);
1395                self.limiter
1396                    .block_until(key, now.saturating_add(secs.saturating_mul(1000)));
1397            }
1398        }
1399        Ok(())
1400    }
1401
1402    /// The seconds in a `Retry-After` header, if it has them.
1403    fn retry_after_secs(headers: &HeaderMap) -> Option<u64> {
1404        headers
1405            .get(header::RETRY_AFTER)
1406            .and_then(|v| v.to_str().ok())
1407            .and_then(|v| v.trim().parse().ok())
1408    }
1409
1410    /// Latest rate-limit status ESI reported for a route group
1411    /// (the `X-Ratelimit-Group` header value, e.g. `market`).
1412    ///
1413    /// Returns `None` if no response from that group has been seen yet.
1414    /// Routes not yet moved to ESI's rate limiter do not send these
1415    /// headers; they are covered by [`Esi::is_error_limited`] instead.
1416    pub async fn rate_limit_status(&self, group: &str) -> Option<RateLimitStatus> {
1417        self.rate_limits.read().await.get(group).cloned()
1418    }
1419
1420    /// Latest rate-limit status for every route group seen so far.
1421    pub async fn rate_limit_statuses(&self) -> HashMap<String, RateLimitStatus> {
1422        self.rate_limits.read().await.clone()
1423    }
1424
1425    async fn process_error_limit_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
1426        match (
1427            headers.get(ERROR_LIMIT_REMAIN_HEADER),
1428            headers.get(ERROR_LIMIT_RESET_HEADER),
1429        ) {
1430            (Some(remain_header), Some(reset_header)) => {
1431                let remaining_limit = remain_header
1432                    .to_str()?
1433                    .parse::<i32>()
1434                    .map_err(|e| EsiError::HeaderParseError(ERROR_LIMIT_REMAIN_HEADER.into(), e))?;
1435                let resets_in = reset_header
1436                    .to_str()?
1437                    .parse::<i64>()
1438                    .map_err(|e| EsiError::HeaderParseError(ERROR_LIMIT_RESET_HEADER.into(), e))?;
1439
1440                let expires_at_millis = current_time_millis()? + resets_in * 1000;
1441
1442                self.error_limit_state
1443                    .write()
1444                    .await
1445                    .replace(ErrorLimitState {
1446                        remaining_limit,
1447                        expires_at_millis,
1448                    });
1449                Ok(())
1450            }
1451            _ => Ok(()),
1452        }
1453    }
1454
1455    async fn assert_not_error_limited(&self) -> Result<(), EsiError> {
1456        match self.is_error_limited().await? {
1457            Limited { for_millis } => Err(EsiError::ErrorLimited(for_millis)),
1458            NotLimited => Ok(()),
1459        }
1460    }
1461
1462    /// Returns whether we have temporarily encountered the error limit due to too many failed responses.
1463    ///
1464    /// If this returns true, then this client will refuse to process further requests.
1465    pub async fn is_error_limited(&self) -> Result<ErrorLimitStatus, EsiError> {
1466        match &self.error_limit_state.read().await.as_ref() {
1467            None => Ok(NotLimited),
1468            Some(state) => {
1469                if state.remaining_limit > 0 {
1470                    return Ok(NotLimited);
1471                }
1472                let remaining_time = state.expires_at_millis - current_time_millis()?;
1473                if remaining_time < 0 {
1474                    return Ok(NotLimited);
1475                }
1476                Ok(Limited {
1477                    for_millis: remaining_time,
1478                })
1479            }
1480        }
1481    }
1482
1483    /// Retrieve this struct's OpenAPI specification.
1484    ///
1485    /// Use in tandem with [EsiBuilder::spec].
1486    pub fn get_spec(&self) -> Option<&Spec> {
1487        self.spec.as_ref()
1488    }
1489
1490    /// Call endpoints under the "Access List" group in ESI.
1491    pub fn group_access_list(&self) -> AccessListGroup<'_> {
1492        AccessListGroup { esi: self }
1493    }
1494
1495    /// Call endpoints under the "Activities" group in ESI.
1496    pub fn group_activities(&self) -> ActivitiesGroup<'_> {
1497        ActivitiesGroup { esi: self }
1498    }
1499
1500    /// Call endpoints under the "alliance" group in ESI.
1501    pub fn group_alliance(&self) -> AllianceGroup<'_> {
1502        AllianceGroup { esi: self }
1503    }
1504
1505    /// Call endpoints under the "Assets" group in ESI.
1506    pub fn group_assets(&self) -> AssetsGroup<'_> {
1507        AssetsGroup { esi: self }
1508    }
1509
1510    /// Call endpoints under the "Bookmarks" group in ESI.
1511    pub fn group_bookmarks(&self) -> BookmarksGroup<'_> {
1512        BookmarksGroup { esi: self }
1513    }
1514
1515    /// Call endpoints under the "Calendar" group in ESI.
1516    pub fn group_calendar(&self) -> CalendarGroup<'_> {
1517        CalendarGroup { esi: self }
1518    }
1519
1520    /// Call endpoints under the "Character" group in ESI.
1521    pub fn group_character(&self) -> CharacterGroup<'_> {
1522        CharacterGroup { esi: self }
1523    }
1524
1525    /// Call endpoints under the "Clones" group in ESI.
1526    pub fn group_clones(&self) -> ClonesGroup<'_> {
1527        ClonesGroup { esi: self }
1528    }
1529
1530    /// Call endpoints under the "Contacts" group in ESI.
1531    pub fn group_contacts(&self) -> ContactsGroup<'_> {
1532        ContactsGroup { esi: self }
1533    }
1534
1535    /// Call endpoints under the "Contracts" group in ESI.
1536    pub fn group_contracts(&self) -> ContractsGroup<'_> {
1537        ContractsGroup { esi: self }
1538    }
1539
1540    /// Call endpoints under the "Corporation" group in ESI.
1541    pub fn group_corporation(&self) -> CorporationGroup<'_> {
1542        CorporationGroup { esi: self }
1543    }
1544
1545    /// Call endpoints under the "Corporation Projects" group in ESI.
1546    pub fn group_corporation_projects(&self) -> CorporationProjectsGroup<'_> {
1547        CorporationProjectsGroup { esi: self }
1548    }
1549
1550    /// Call endpoints under the "Dogma" group in ESI.
1551    pub fn group_dogma(&self) -> DogmaGroup<'_> {
1552        DogmaGroup { esi: self }
1553    }
1554
1555    /// Call endpoints under the "FactionWarfare" group in ESI.
1556    pub fn group_faction_warfare(&self) -> FactionWarfareGroup<'_> {
1557        FactionWarfareGroup { esi: self }
1558    }
1559
1560    /// Call endpoints under the "Fittings" group in ESI.
1561    pub fn group_fittings(&self) -> FittingsGroup<'_> {
1562        FittingsGroup { esi: self }
1563    }
1564
1565    /// Call endpoints under the "Fleets" group in ESI.
1566    pub fn group_fleets(&self) -> FleetsGroup<'_> {
1567        FleetsGroup { esi: self }
1568    }
1569
1570    /// Call endpoints under the "Incursions" group in ESI.
1571    pub fn group_incursions(&self) -> IncursionsGroup<'_> {
1572        IncursionsGroup { esi: self }
1573    }
1574
1575    /// Call endpoints under the "Industry" group in ESI.
1576    pub fn group_industry(&self) -> IndustryGroup<'_> {
1577        IndustryGroup { esi: self }
1578    }
1579
1580    /// Call endpoints under the "Insurance" group in ESI.
1581    pub fn group_insurance(&self) -> InsuranceGroup<'_> {
1582        InsuranceGroup { esi: self }
1583    }
1584
1585    /// Call endpoints under the "Killmails" group in ESI.
1586    pub fn group_killmails(&self) -> KillmailsGroup<'_> {
1587        KillmailsGroup { esi: self }
1588    }
1589
1590    /// Call endpoints under the "Location" group in ESI.
1591    pub fn group_location(&self) -> LocationGroup<'_> {
1592        LocationGroup { esi: self }
1593    }
1594
1595    /// Call endpoints under the "Loyalty" group in ESI.
1596    pub fn group_loyalty(&self) -> LoyaltyGroup<'_> {
1597        LoyaltyGroup { esi: self }
1598    }
1599
1600    /// Call endpoints under the "Mail" group in ESI.
1601    pub fn group_mail(&self) -> MailGroup<'_> {
1602        MailGroup { esi: self }
1603    }
1604
1605    /// Call endpoints under the "Market" group in ESI.
1606    pub fn group_market(&self) -> MarketGroup<'_> {
1607        MarketGroup { esi: self }
1608    }
1609
1610    /// Call endpoints under the "Opportunities" group in ESI.
1611    pub fn group_opportunities(&self) -> OpportunitiesGroup<'_> {
1612        OpportunitiesGroup { esi: self }
1613    }
1614
1615    /// Call endpoints under the "PlanetaryInteraction" group in ESI.
1616    pub fn group_planetary_interaction(&self) -> PlanetaryInteractionGroup<'_> {
1617        PlanetaryInteractionGroup { esi: self }
1618    }
1619
1620    /// Call endpoints under the "Routes" group in ESI.
1621    pub fn group_routes(&self) -> RoutesGroup<'_> {
1622        RoutesGroup { esi: self }
1623    }
1624
1625    /// Call endpoints under the "Cosmetics" group in ESI.
1626    pub fn group_cosmetics(&self) -> CosmeticsGroup<'_> {
1627        CosmeticsGroup { esi: self }
1628    }
1629
1630    /// Call endpoints under the "Paragon Hub" group in ESI.
1631    pub fn group_paragon_hub(&self) -> ParagonHubGroup<'_> {
1632        ParagonHubGroup { esi: self }
1633    }
1634
1635    /// Call endpoints under the "Freelance Jobs" group in ESI.
1636    pub fn group_freelance_jobs(&self) -> FreelanceJobsGroup<'_> {
1637        FreelanceJobsGroup { esi: self }
1638    }
1639
1640    /// Call endpoints under the "Military Campaigns" group in ESI.
1641    pub fn group_military_campaigns(&self) -> MilitaryCampaignsGroup<'_> {
1642        MilitaryCampaignsGroup { esi: self }
1643    }
1644
1645    /// Call endpoints under the "Meta" group in ESI.
1646    pub fn group_meta(&self) -> MetaGroup<'_> {
1647        MetaGroup { esi: self }
1648    }
1649
1650    /// Call endpoints under the "Search" group in ESI.
1651    pub fn group_search(&self) -> SearchGroup<'_> {
1652        SearchGroup { esi: self }
1653    }
1654
1655    /// Call endpoints under the "Skills" group in ESI.
1656    pub fn group_skills(&self) -> SkillsGroup<'_> {
1657        SkillsGroup { esi: self }
1658    }
1659
1660    /// Call endpoints under the "Sovereignty" group in ESI.
1661    pub fn group_sovereignty(&self) -> SovereigntyGroup<'_> {
1662        SovereigntyGroup { esi: self }
1663    }
1664
1665    /// Call endpoints under the "Structures" group in ESI.
1666    pub fn group_structures(&self) -> StructuresGroup<'_> {
1667        StructuresGroup { esi: self }
1668    }
1669
1670    /// Call endpoints under the "Status" group in ESI.
1671    pub fn group_status(&self) -> StatusGroup<'_> {
1672        StatusGroup { esi: self }
1673    }
1674
1675    /// Call endpoints under the "Universe" group in ESI.
1676    pub fn group_universe(&self) -> UniverseGroup<'_> {
1677        UniverseGroup { esi: self }
1678    }
1679
1680    /// Call endpoints under the "UserInterface" group in ESI.
1681    pub fn group_user_interface(&self) -> UserInterfaceGroup<'_> {
1682        UserInterfaceGroup { esi: self }
1683    }
1684
1685    /// Call endpoints under the "Wallet" group in ESI.
1686    pub fn group_wallet(&self) -> WalletGroup<'_> {
1687        WalletGroup { esi: self }
1688    }
1689
1690    /// Call endpoints under the "Wars" group in ESI.
1691    pub fn group_wars(&self) -> WarsGroup<'_> {
1692        WarsGroup { esi: self }
1693    }
1694}
1695
1696/// Get the current system timestamp since the epoch.
1697fn current_time_millis() -> Result<i64, EsiError> {
1698    Ok(SystemTime::now()
1699        .duration_since(UNIX_EPOCH)?
1700        .as_millis()
1701        .try_into()
1702        .expect("i64 overflow for time"))
1703}
1704
1705#[cfg(test)]
1706mod tests {
1707    use super::{
1708        parse_rate_limit, AuthenticateResponse, Esi, ERROR_LIMIT_REMAIN_HEADER,
1709        ERROR_LIMIT_RESET_HEADER, RATE_LIMIT_GROUP_HEADER, RATE_LIMIT_LIMIT_HEADER,
1710        RATE_LIMIT_REMAINING_HEADER, RATE_LIMIT_USED_HEADER,
1711    };
1712    use crate::errors::EsiError;
1713    use crate::prelude::EsiBuilder;
1714    use crate::spec::Spec;
1715
1716    #[test]
1717    fn test_spec_metadata_helpers() {
1718        let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
1719        let esi = EsiBuilder::new()
1720            .user_agent("t")
1721            .spec(Some(spec))
1722            .build()
1723            .unwrap();
1724        let op = "GetCorporationsCorporationIdBlueprints";
1725        assert_eq!(esi.required_roles(op).unwrap(), ["Director"]);
1726        assert_eq!(
1727            esi.missing_scopes(op, "esi-assets.read_assets.v1").unwrap(),
1728            ["esi-corporations.read_blueprints.v1"]
1729        );
1730        assert!(esi
1731            .missing_scopes(op, "a esi-corporations.read_blueprints.v1")
1732            .unwrap()
1733            .is_empty());
1734        assert!(matches!(
1735            esi.required_scopes("Nope"),
1736            Err(EsiError::UnknownOperationID(_))
1737        ));
1738        assert!(esi.declared_rate_limits().unwrap().len() > 5);
1739    }
1740
1741    #[test]
1742    fn test_pages_header_and_empty_body() {
1743        let mut headers = reqwest::header::HeaderMap::new();
1744        assert_eq!(Esi::pages_header(&headers), None);
1745        headers.insert("x-pages", "7".parse().unwrap());
1746        assert_eq!(Esi::pages_header(&headers), Some(7));
1747        let unit: () = Esi::parse_body("").unwrap();
1748        assert_eq!(unit, ());
1749        let numbers: Vec<i64> = Esi::parse_body("[1, 2]").unwrap();
1750        assert_eq!(numbers, [1, 2]);
1751    }
1752    use http::{HeaderMap, HeaderValue};
1753    use std::time::Duration;
1754
1755    const FIXTURE: &str = include_str!("../resources/test/openapi.json");
1756
1757    fn esi_with_fixture() -> Esi {
1758        let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
1759        EsiBuilder::new()
1760            .user_agent("Client test, not meant to request")
1761            .spec(Some(spec))
1762            .build()
1763            .unwrap()
1764    }
1765
1766    #[test]
1767    fn test_resolve_openapi_op_id() {
1768        let esi = esi_with_fixture();
1769        assert_eq!(
1770            esi.get_endpoint_for_op_id("GetMarketsRegionIdOrders")
1771                .unwrap(),
1772            "markets/{region_id}/orders"
1773        );
1774        assert_eq!(
1775            esi.get_endpoint_for_op_id("PostUniverseIds").unwrap(),
1776            "universe/ids"
1777        );
1778    }
1779
1780    #[test]
1781    fn test_resolve_legacy_op_id() {
1782        let esi = esi_with_fixture();
1783        assert_eq!(
1784            esi.get_endpoint_for_op_id("get_markets_region_id_orders")
1785                .unwrap(),
1786            "markets/{region_id}/orders"
1787        );
1788        assert_eq!(
1789            esi.get_endpoint_for_op_id("get_characters_character_id")
1790                .unwrap(),
1791            "characters/{character_id}"
1792        );
1793    }
1794
1795    #[test]
1796    fn test_all_legacy_ids_resolve() {
1797        let esi = esi_with_fixture();
1798        for (legacy, openapi) in crate::legacy::LEGACY_OP_IDS {
1799            esi.get_endpoint_for_op_id(openapi)
1800                .unwrap_or_else(|_| panic!("{openapi} (from {legacy}) missing from spec"));
1801        }
1802    }
1803
1804    #[test]
1805    fn test_resolve_unknown_op_id() {
1806        let esi = esi_with_fixture();
1807        match esi.get_endpoint_for_op_id("GetNothingHere") {
1808            Err(EsiError::UnknownOperationID(id)) => assert_eq!(id, "GetNothingHere"),
1809            other => panic!("Unexpected result: {other:?}"),
1810        }
1811    }
1812
1813    #[test]
1814    fn test_resolve_without_spec() {
1815        let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1816        assert!(matches!(
1817            esi.get_endpoint_for_op_id("GetMarketsPrices"),
1818            Err(EsiError::EmptySpec)
1819        ));
1820    }
1821
1822    #[test]
1823    fn test_parse_rate_limit() {
1824        assert_eq!(parse_rate_limit("150/15m"), (Some(150), Some(900)));
1825        assert_eq!(parse_rate_limit("20/1h"), (Some(20), Some(3600)));
1826        assert_eq!(parse_rate_limit("300/30s"), (Some(300), Some(30)));
1827        assert_eq!(parse_rate_limit("10/5x"), (Some(10), None));
1828        assert_eq!(parse_rate_limit("garbage"), (None, None));
1829    }
1830
1831    #[tokio::test]
1832    async fn test_rate_limit_headers() {
1833        let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1834        let mut headers = HeaderMap::new();
1835        headers.append(RATE_LIMIT_GROUP_HEADER, HeaderValue::from_static("market"));
1836        headers.append(RATE_LIMIT_LIMIT_HEADER, HeaderValue::from_static("150/15m"));
1837        headers.append(RATE_LIMIT_REMAINING_HEADER, HeaderValue::from_static("148"));
1838        headers.append(RATE_LIMIT_USED_HEADER, HeaderValue::from_static("2"));
1839        esi.process_response_headers(&headers)
1840            .await
1841            .expect("Should parse");
1842        let status = esi.rate_limit_status("market").await.expect("recorded");
1843        assert_eq!(status.limit, "150/15m");
1844        assert_eq!(status.max_tokens, Some(150));
1845        assert_eq!(status.window_secs, Some(900));
1846        assert_eq!(status.remaining, 148);
1847        assert_eq!(status.used, 2);
1848        assert!(esi.rate_limit_status("other").await.is_none());
1849        assert_eq!(esi.rate_limit_statuses().await.len(), 1);
1850    }
1851
1852    #[tokio::test]
1853    async fn test_no_rate_limit_headers() {
1854        let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1855        esi.process_response_headers(&HeaderMap::new())
1856            .await
1857            .expect("Should parse");
1858        assert!(esi.rate_limit_statuses().await.is_empty());
1859    }
1860
1861    #[test]
1862    fn test_status_error_429() {
1863        let mut headers = HeaderMap::new();
1864        headers.append(RATE_LIMIT_GROUP_HEADER, HeaderValue::from_static("market"));
1865        headers.append(http::header::RETRY_AFTER, HeaderValue::from_static("12"));
1866        match Esi::status_error(429, &headers) {
1867            EsiError::RateLimited {
1868                group,
1869                retry_after_secs,
1870            } => {
1871                assert_eq!(group.as_deref(), Some("market"));
1872                assert_eq!(retry_after_secs, Some(12));
1873            }
1874            other => panic!("Unexpected error: {other}"),
1875        }
1876        assert!(matches!(
1877            Esi::status_error(404, &headers),
1878            EsiError::InvalidStatusCode(404)
1879        ));
1880    }
1881
1882    #[test]
1883    fn test_authenticateresponse_deserialize() {
1884        let source = r#"{
1885            "access_token": "abc",
1886            "expires_in": 1000,
1887            "refresh_token": "def"
1888          }"#;
1889        let data: AuthenticateResponse = serde_json::from_str(source).unwrap();
1890
1891        assert_eq!(data.access_token, "abc");
1892        assert_eq!(data.expires_in, 1000);
1893        assert_eq!(data.refresh_token, Some("def".to_owned()));
1894    }
1895
1896    #[test]
1897    fn test_authenticateresponse_deserialize_no_refresh_token() {
1898        let source = r#"{
1899            "access_token": "abc",
1900            "expires_in": 1000,
1901            "refresh_token": null
1902          }"#;
1903        let data: AuthenticateResponse = serde_json::from_str(source).unwrap();
1904
1905        assert_eq!(data.access_token, "abc");
1906        assert_eq!(data.expires_in, 1000);
1907        assert_eq!(data.refresh_token, None);
1908    }
1909
1910    #[tokio::test]
1911    async fn test_error_limit_header_not_limited() {
1912        let esi = EsiBuilder::default()
1913            .user_agent("Client test, not meant to request")
1914            .build()
1915            .unwrap();
1916        let mut headers = HeaderMap::new();
1917        headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("100"));
1918        headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("5"));
1919        esi.process_error_limit_headers(&headers)
1920            .await
1921            .expect("Should parse");
1922        esi.assert_not_error_limited()
1923            .await
1924            .expect("Should not be error limited");
1925    }
1926
1927    #[tokio::test]
1928    async fn test_error_limit_header_limited() {
1929        let esi = EsiBuilder::default()
1930            .user_agent("Client test, not meant to request")
1931            .build()
1932            .unwrap();
1933        let mut headers = HeaderMap::new();
1934        headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("0"));
1935        headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("2"));
1936        esi.process_error_limit_headers(&headers)
1937            .await
1938            .expect("Should parse");
1939        let err = esi
1940            .assert_not_error_limited()
1941            .await
1942            .expect_err("Should be limited");
1943        match err {
1944            EsiError::ErrorLimited(millis) => {
1945                assert!(millis <= 2000)
1946            }
1947            _ => panic!("Unexpected error: {}", err),
1948        }
1949    }
1950
1951    #[tokio::test]
1952    #[ignore] // This is a bit slow
1953    async fn test_error_limit_expired_limit() {
1954        let esi = EsiBuilder::default()
1955            .user_agent("Client test, not meant to request")
1956            .build()
1957            .unwrap();
1958        let mut headers = HeaderMap::new();
1959        headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("0"));
1960        headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("2"));
1961        esi.process_error_limit_headers(&headers)
1962            .await
1963            .expect("Should parse");
1964        println!("Waiting 2 seconds ..");
1965        tokio::time::sleep(Duration::from_millis(2050)).await;
1966        esi.assert_not_error_limited()
1967            .await
1968            .expect("Should not be error limited");
1969    }
1970}