1use 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";
44const CURSOR_PAGE_LIMIT: &str = "100";
46pub const COMPATIBILITY_DATE_DEFAULT: &str = "2026-08-18";
51
52#[derive(Debug, Deserialize)]
54struct AuthenticateResponse {
55 access_token: String,
56 expires_in: u64,
57 refresh_token: Option<String>,
58}
59
60#[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#[derive(Copy, Clone, Debug)]
77pub enum ErrorLimitStatus {
78 Limited {
80 for_millis: i64,
82 },
83 NotLimited,
85}
86
87#[derive(Clone, Debug)]
89struct SpecInfo {
90 compatibility_date: String,
92 etag: Option<String>,
93 last_modified: Option<String>,
94 expires_at: i64,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct RateLimitStatus {
107 pub group: String,
109 pub limit: String,
111 pub max_tokens: Option<u64>,
113 pub window_secs: Option<u64>,
115 pub remaining: i64,
117 pub used: i64,
120 pub updated_at_millis: i64,
122}
123
124fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum RequestType {
153 Public,
155 Authenticated,
157}
158
159pub struct AuthenticationInformation {
161 pub authorization_url: String,
163 pub state: String,
168 pub pkce_verifier: Option<PkceVerifier>,
171}
172
173#[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 pub access_token: Option<String>,
203 pub access_expiration: Option<i64>,
205 pub refresh_token: Option<String>,
207 pub(crate) client: Client,
209 pub(crate) spec: Option<Spec>,
210 spec_info: Option<SpecInfo>,
212 index: SpecIndex,
214 error_limit_state: Arc<RwLock<Option<ErrorLimitState>>>,
215 rate_limits: Arc<RwLock<HashMap<String, RateLimitStatus>>>,
216 limiter: Arc<RateLimiter>,
218 pub(crate) rate_limit_policy: RateLimitPolicy,
220 cache: Option<Arc<RwLock<ResponseCache>>>,
222 pub(crate) page_concurrency: usize,
224 pub(crate) language: Option<Language>,
226 pub(crate) tenant: Option<String>,
228}
229
230impl Esi {
231 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(¶ms))
840 .await
841 }
842
843 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(¶ms), 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 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 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 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 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 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 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 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 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 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 fn request_headers(&self, request_type: &RequestType) -> EsiResult<HeaderMap> {
1100 let mut map = HeaderMap::new();
1101 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 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 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 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 pub fn required_scopes(&self, op_id: &str) -> EsiResult<Vec<String>> {
1224 Ok(self.spec_operation(op_id)?.scopes())
1225 }
1226
1227 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 pub fn required_roles(&self, op_id: &str) -> EsiResult<Vec<String>> {
1242 Ok(self.spec_operation(op_id)?.required_roles.clone())
1243 }
1244
1245 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 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 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 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 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 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 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 pub async fn rate_limit_status(&self, group: &str) -> Option<RateLimitStatus> {
1417 self.rate_limits.read().await.get(group).cloned()
1418 }
1419
1420 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 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 pub fn get_spec(&self) -> Option<&Spec> {
1487 self.spec.as_ref()
1488 }
1489
1490 pub fn group_access_list(&self) -> AccessListGroup<'_> {
1492 AccessListGroup { esi: self }
1493 }
1494
1495 pub fn group_activities(&self) -> ActivitiesGroup<'_> {
1497 ActivitiesGroup { esi: self }
1498 }
1499
1500 pub fn group_alliance(&self) -> AllianceGroup<'_> {
1502 AllianceGroup { esi: self }
1503 }
1504
1505 pub fn group_assets(&self) -> AssetsGroup<'_> {
1507 AssetsGroup { esi: self }
1508 }
1509
1510 pub fn group_bookmarks(&self) -> BookmarksGroup<'_> {
1512 BookmarksGroup { esi: self }
1513 }
1514
1515 pub fn group_calendar(&self) -> CalendarGroup<'_> {
1517 CalendarGroup { esi: self }
1518 }
1519
1520 pub fn group_character(&self) -> CharacterGroup<'_> {
1522 CharacterGroup { esi: self }
1523 }
1524
1525 pub fn group_clones(&self) -> ClonesGroup<'_> {
1527 ClonesGroup { esi: self }
1528 }
1529
1530 pub fn group_contacts(&self) -> ContactsGroup<'_> {
1532 ContactsGroup { esi: self }
1533 }
1534
1535 pub fn group_contracts(&self) -> ContractsGroup<'_> {
1537 ContractsGroup { esi: self }
1538 }
1539
1540 pub fn group_corporation(&self) -> CorporationGroup<'_> {
1542 CorporationGroup { esi: self }
1543 }
1544
1545 pub fn group_corporation_projects(&self) -> CorporationProjectsGroup<'_> {
1547 CorporationProjectsGroup { esi: self }
1548 }
1549
1550 pub fn group_dogma(&self) -> DogmaGroup<'_> {
1552 DogmaGroup { esi: self }
1553 }
1554
1555 pub fn group_faction_warfare(&self) -> FactionWarfareGroup<'_> {
1557 FactionWarfareGroup { esi: self }
1558 }
1559
1560 pub fn group_fittings(&self) -> FittingsGroup<'_> {
1562 FittingsGroup { esi: self }
1563 }
1564
1565 pub fn group_fleets(&self) -> FleetsGroup<'_> {
1567 FleetsGroup { esi: self }
1568 }
1569
1570 pub fn group_incursions(&self) -> IncursionsGroup<'_> {
1572 IncursionsGroup { esi: self }
1573 }
1574
1575 pub fn group_industry(&self) -> IndustryGroup<'_> {
1577 IndustryGroup { esi: self }
1578 }
1579
1580 pub fn group_insurance(&self) -> InsuranceGroup<'_> {
1582 InsuranceGroup { esi: self }
1583 }
1584
1585 pub fn group_killmails(&self) -> KillmailsGroup<'_> {
1587 KillmailsGroup { esi: self }
1588 }
1589
1590 pub fn group_location(&self) -> LocationGroup<'_> {
1592 LocationGroup { esi: self }
1593 }
1594
1595 pub fn group_loyalty(&self) -> LoyaltyGroup<'_> {
1597 LoyaltyGroup { esi: self }
1598 }
1599
1600 pub fn group_mail(&self) -> MailGroup<'_> {
1602 MailGroup { esi: self }
1603 }
1604
1605 pub fn group_market(&self) -> MarketGroup<'_> {
1607 MarketGroup { esi: self }
1608 }
1609
1610 pub fn group_opportunities(&self) -> OpportunitiesGroup<'_> {
1612 OpportunitiesGroup { esi: self }
1613 }
1614
1615 pub fn group_planetary_interaction(&self) -> PlanetaryInteractionGroup<'_> {
1617 PlanetaryInteractionGroup { esi: self }
1618 }
1619
1620 pub fn group_routes(&self) -> RoutesGroup<'_> {
1622 RoutesGroup { esi: self }
1623 }
1624
1625 pub fn group_cosmetics(&self) -> CosmeticsGroup<'_> {
1627 CosmeticsGroup { esi: self }
1628 }
1629
1630 pub fn group_paragon_hub(&self) -> ParagonHubGroup<'_> {
1632 ParagonHubGroup { esi: self }
1633 }
1634
1635 pub fn group_freelance_jobs(&self) -> FreelanceJobsGroup<'_> {
1637 FreelanceJobsGroup { esi: self }
1638 }
1639
1640 pub fn group_military_campaigns(&self) -> MilitaryCampaignsGroup<'_> {
1642 MilitaryCampaignsGroup { esi: self }
1643 }
1644
1645 pub fn group_meta(&self) -> MetaGroup<'_> {
1647 MetaGroup { esi: self }
1648 }
1649
1650 pub fn group_search(&self) -> SearchGroup<'_> {
1652 SearchGroup { esi: self }
1653 }
1654
1655 pub fn group_skills(&self) -> SkillsGroup<'_> {
1657 SkillsGroup { esi: self }
1658 }
1659
1660 pub fn group_sovereignty(&self) -> SovereigntyGroup<'_> {
1662 SovereigntyGroup { esi: self }
1663 }
1664
1665 pub fn group_structures(&self) -> StructuresGroup<'_> {
1667 StructuresGroup { esi: self }
1668 }
1669
1670 pub fn group_status(&self) -> StatusGroup<'_> {
1672 StatusGroup { esi: self }
1673 }
1674
1675 pub fn group_universe(&self) -> UniverseGroup<'_> {
1677 UniverseGroup { esi: self }
1678 }
1679
1680 pub fn group_user_interface(&self) -> UserInterfaceGroup<'_> {
1682 UserInterfaceGroup { esi: self }
1683 }
1684
1685 pub fn group_wallet(&self) -> WalletGroup<'_> {
1687 WalletGroup { esi: self }
1688 }
1689
1690 pub fn group_wars(&self) -> WarsGroup<'_> {
1692 WarsGroup { esi: self }
1693 }
1694}
1695
1696fn 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] 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}