1use crate::{
4 client::ErrorLimitStatus::{Limited, NotLimited},
5 groups::*,
6 legacy,
7 pkce::{self, PkceVerifier},
8 prelude::*,
9 spec::Spec,
10};
11use base64::engine::{general_purpose::STANDARD as base64, Engine};
12use log::{debug, error, warn};
13#[cfg(feature = "random_state")]
14use rand::{distr::Alphanumeric, RngExt};
15use reqwest::{
16 header::{self, HeaderMap, HeaderValue},
17 Client, Method,
18};
19use serde::de::DeserializeOwned;
20use std::{
21 collections::HashMap,
22 str::FromStr,
23 sync::Arc,
24 time::{SystemTime, UNIX_EPOCH},
25};
26use tokio::sync::RwLock;
27
28const BASE_URL: &str = "https://esi.evetech.net/";
29const AUTHORIZE_URL: &str = "https://login.eveonline.com/v2/oauth/authorize";
30const TOKEN_URL: &str = "https://login.eveonline.com/v2/oauth/token";
31const SPEC_URL: &str = "https://esi.evetech.net/meta/openapi.json";
32const ERROR_LIMIT_REMAIN_HEADER: &str = "x-esi-error-limit-remain";
33const ERROR_LIMIT_RESET_HEADER: &str = "x-esi-error-limit-reset";
34const RATE_LIMIT_GROUP_HEADER: &str = "x-ratelimit-group";
35const RATE_LIMIT_LIMIT_HEADER: &str = "x-ratelimit-limit";
36const RATE_LIMIT_REMAINING_HEADER: &str = "x-ratelimit-remaining";
37const RATE_LIMIT_USED_HEADER: &str = "x-ratelimit-used";
38
39static COMPATIBILITY_HEADER: &str = "X-Compatibility-Date";
40pub const COMPATIBILITY_DATE_DEFAULT: &str = "2026-08-18";
45
46#[derive(Debug, Deserialize)]
48struct AuthenticateResponse {
49 access_token: String,
50 expires_in: u64,
51 refresh_token: Option<String>,
52}
53
54#[derive(Debug, Deserialize)]
56struct RefreshTokenAuthenticateResponse {
57 access_token: String,
58 expires_in: u64,
59 refresh_token: String,
60}
61
62#[derive(Copy, Clone, Debug)]
63struct ErrorLimitState {
64 remaining_limit: i32,
65 expires_at_millis: i64,
66}
67
68#[derive(Copy, Clone, Debug)]
71pub enum ErrorLimitStatus {
72 Limited {
74 for_millis: i64,
76 },
77 NotLimited,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct RateLimitStatus {
90 pub group: String,
92 pub limit: String,
94 pub max_tokens: Option<u64>,
96 pub window_secs: Option<u64>,
98 pub remaining: i64,
100 pub used: i64,
103 pub updated_at_millis: i64,
105}
106
107fn parse_rate_limit(value: &str) -> (Option<u64>, Option<u64>) {
110 let Some((tokens, window)) = value.trim().split_once('/') else {
111 return (value.trim().parse().ok(), None);
112 };
113 let tokens = tokens.trim().parse().ok();
114 let window = window.trim();
115 let split = window
116 .find(|c: char| !c.is_ascii_digit())
117 .unwrap_or(window.len());
118 let (amount, unit) = window.split_at(split);
119 let amount: Option<u64> = amount.parse().ok();
120 let multiplier = match unit {
121 "" | "s" => Some(1),
122 "m" => Some(60),
123 "h" => Some(3600),
124 "d" => Some(86_400),
125 _ => None,
126 };
127 let window_secs = amount.zip(multiplier).map(|(a, m)| a * m);
128 (tokens, window_secs)
129}
130
131#[derive(Debug, PartialEq, Eq)]
135pub enum RequestType {
136 Public,
138 Authenticated,
140}
141
142pub struct AuthenticationInformation {
144 pub authorization_url: String,
146 pub state: String,
151 pub pkce_verifier: Option<PkceVerifier>,
154}
155
156#[derive(Clone, Debug)]
173pub struct Esi {
174 pub(crate) compatibility_date: String,
175 pub(crate) client_id: Option<String>,
176 pub(crate) client_secret: Option<String>,
177 pub(crate) callback_url: Option<String>,
178 pub(crate) base_api_url: String,
179 pub(crate) authorize_url: String,
180 pub(crate) token_url: String,
181 pub(crate) spec_url: String,
182 pub(crate) scope: String,
183 pub(crate) application_auth: bool,
184 pub access_token: Option<String>,
186 pub access_expiration: Option<i64>,
188 pub refresh_token: Option<String>,
190 pub(crate) client: Client,
192 pub(crate) spec: Option<Spec>,
193 op_index: HashMap<String, String>,
195 error_limit_state: Arc<RwLock<Option<ErrorLimitState>>>,
196 rate_limits: Arc<RwLock<HashMap<String, RateLimitStatus>>>,
197}
198
199impl Esi {
200 pub(crate) fn from_builder(builder: EsiBuilder) -> EsiResult<Self> {
202 let client = builder.construct_client()?;
203 let compatibility_date = builder
204 .compatibility_date
205 .unwrap_or_else(|| COMPATIBILITY_DATE_DEFAULT.to_owned());
206 let op_index = builder
207 .spec
208 .as_ref()
209 .map(Spec::operation_index)
210 .unwrap_or_default();
211 let e = Esi {
212 compatibility_date: compatibility_date.clone(),
213 client_id: builder.client_id,
214 client_secret: builder.client_secret,
215 callback_url: builder.callback_url,
216 base_api_url: builder.base_api_url.unwrap_or(BASE_URL.to_string()),
217 authorize_url: builder.authorize_url.unwrap_or(AUTHORIZE_URL.to_string()),
218 token_url: builder.token_url.unwrap_or(TOKEN_URL.to_string()),
219 spec_url: builder.spec_url.unwrap_or(SPEC_URL.to_string()),
220 scope: builder.scope.unwrap_or_else(|| "".to_owned()),
221 application_auth: builder.application_auth.unwrap_or(false),
222 access_token: builder.access_token,
223 access_expiration: builder.access_expiration,
224 refresh_token: builder.refresh_token,
225 client,
226 spec: builder.spec,
227 op_index,
228 error_limit_state: Arc::new(RwLock::new(None)),
229 rate_limits: Arc::new(RwLock::new(HashMap::new())),
230 };
231 Ok(e)
232 }
233
234 pub async fn update_spec(&mut self) -> EsiResult<()> {
261 debug!(
262 "Updating spec with compatibility date {}",
263 self.compatibility_date
264 );
265 self.assert_not_error_limited().await?;
266 let resp = self
267 .client
268 .get(&self.spec_url)
269 .header(
270 COMPATIBILITY_HEADER,
271 HeaderValue::from_str(&self.compatibility_date)?,
272 )
273 .send()
274 .await?;
275 self.process_response_headers(resp.headers()).await?;
276 if !resp.status().is_success() {
277 error!("Got status {} when requesting spec", resp.status());
278 return Err(Self::status_error(resp.status().as_u16(), resp.headers()));
279 }
280 let data: Spec = resp.json().await?;
281 self.op_index = data.operation_index();
282 self.spec = Some(data);
283 Ok(())
284 }
285
286 fn check_client_info(&self) -> EsiResult<()> {
288 for (name, value) in &[
289 ("client_id", &self.client_id),
290 ("callback_url", &self.callback_url),
291 ] {
292 if value.is_none() {
293 return Err(EsiError::EmptyClientValue(name.to_string()));
294 }
295 }
296
297 if self.client_secret.is_none() {
298 if !self.application_auth {
299 return Err(EsiError::MissingAuthenticationFlowInformation);
300 }
301 } else if self.application_auth {
302 return Err(EsiError::MissingAuthenticationFlowInformation);
303 }
304
305 Ok(())
306 }
307
308 pub fn get_authorize_url(&self) -> EsiResult<AuthenticationInformation> {
335 self.check_client_info()?;
336 #[cfg(feature = "random_state")]
337 let state = rand::rng()
338 .sample_iter(&Alphanumeric)
339 .take(10)
340 .map(char::from)
341 .collect();
342 #[cfg(not(feature = "random_state"))]
343 let state = "esi_openapi_unused".to_string();
344 let mut url = format!(
345 "{}?response_type=code&redirect_uri={}&client_id={}&scope={}&state={state}",
346 self.authorize_url,
347 self.callback_url.as_ref().unwrap(),
348 self.client_id.as_ref().unwrap(),
349 self.scope
350 );
351 let mut pkce_verifier = None;
352 if self.client_secret.is_none() && self.application_auth {
354 let pkce = pkce::generate()?;
355 pkce_verifier = Some(pkce.verifier);
356 url = format!(
357 "{}&code_challenge={}&code_challenge_method=S256",
358 url, pkce.challenge
359 )
360 }
361 Ok(AuthenticationInformation {
362 authorization_url: url,
363 state,
364 pkce_verifier,
365 })
366 }
367
368 fn get_auth_headers(&self) -> EsiResult<HeaderMap> {
369 self.check_client_info()?;
370 let mut map = HeaderMap::new();
371 if let Some(ref secret) = self.client_secret {
372 let value = base64
373 .encode(format!("{}:{secret}", self.client_id.as_ref().unwrap()))
374 .replace(['\n', ' '], "");
375 map.insert(
376 header::AUTHORIZATION,
377 HeaderValue::from_str(&format!("Basic {value}"))?,
378 );
379 }
380 map.insert(
381 header::HOST,
382 HeaderValue::from_static("login.eveonline.com"),
383 );
384 Ok(map)
385 }
386
387 pub async fn authenticate(
428 &mut self,
429 code: &str,
430 pkce_verifier: Option<PkceVerifier>,
431 ) -> EsiResult<Option<TokenClaims>> {
432 debug!("Authenticating with code {code}");
433 self.assert_not_error_limited().await?;
434 let mut body = HashMap::from([("grant_type", "authorization_code"), ("code", code)]);
435 if self.application_auth {
436 let option = self.client_id.as_ref();
437 body.insert("client_id", option.unwrap());
438 body.insert("code_verifier", pkce_verifier.as_ref().unwrap());
439 }
440
441 let resp = self
442 .client
443 .post(&self.token_url)
444 .headers(self.get_auth_headers()?)
445 .form(&body)
446 .send()
447 .await?;
448 if resp.status() != 200 {
449 warn!(
450 "Got status {} when making call to authenticate",
451 resp.status()
452 );
453 return Err(EsiError::InvalidStatusCode(resp.status().as_u16()));
454 }
455 self.process_error_limit_headers(resp.headers()).await?;
456 let data: AuthenticateResponse = resp.json().await?;
457 #[allow(unused_variables)]
458 let claim_data: Option<TokenClaims> = None;
459 #[cfg(feature = "validate_jwt")]
460 let claim_data = Some(
461 crate::jwt_util::validate_jwt(
462 &self.client,
463 &data.access_token,
464 self.client_id.as_ref().unwrap(),
465 )
466 .await?,
467 );
468 self.access_token = Some(data.access_token);
469 self.access_expiration = Some((data.expires_in as i64 * 1_000) + current_time_millis()?);
471 self.refresh_token = data.refresh_token;
472 Ok(claim_data)
473 }
474
475 pub async fn use_refresh_token(&mut self, refresh_token: &str) -> EsiResult<()> {
498 self.refresh_access_token(Some(refresh_token)).await?;
499 Ok(())
500 }
501
502 pub async fn refresh_access_token(&mut self, refresh_token: Option<&str>) -> EsiResult<()> {
534 self.assert_not_error_limited().await?;
535 let token = if let Some(token) = refresh_token {
536 token.to_string()
537 } else if let Some(token) = self.refresh_token.clone() {
538 token
539 } else {
540 return Err(EsiError::NoRefreshTokenAvailable);
541 };
542
543 debug!("Authenticating with refresh token");
544 let mut body = HashMap::from([("grant_type", "refresh_token"), ("refresh_token", &token)]);
545 if self.application_auth {
546 let option = self.client_id.as_ref();
547 body.insert("client_id", option.unwrap());
548 }
549 let resp = self
550 .client
551 .post(&self.token_url)
552 .headers(self.get_auth_headers()?)
553 .form(&body)
554 .send()
555 .await?;
556 self.process_error_limit_headers(resp.headers()).await?;
557 if resp.status() != 200 {
558 warn!(
559 "Got status {} when making call to authenticate via a refresh token",
560 resp.status()
561 );
562 return Err(EsiError::InvalidStatusCode(resp.status().as_u16()));
563 }
564 let data: RefreshTokenAuthenticateResponse = resp.json().await?;
565 self.access_token = Some(data.access_token);
566 self.access_expiration = Some((data.expires_in as i64 * 1_000) + current_time_millis()?);
568 self.refresh_token = Some(data.refresh_token);
569 Ok(())
570 }
571
572 pub async fn query<T: DeserializeOwned>(
603 &self,
604 method: &str,
605 request_type: RequestType,
606 endpoint: &str,
607 query: Option<&[(&str, &str)]>,
608 body: Option<&str>,
609 ) -> EsiResult<T> {
610 debug!("Making {request_type:?} {method} request to {endpoint} with query: {query:?}");
611 self.assert_not_error_limited().await?;
612 if request_type == RequestType::Authenticated {
613 if self.access_token.is_none() {
614 return Err(EsiError::MissingAuthentication);
615 }
616 if self.access_expiration.unwrap() < current_time_millis()? {
617 return Err(EsiError::AccessTokenExpired);
618 }
619 }
620 let headers = {
621 let mut map = HeaderMap::new();
622 if request_type == RequestType::Authenticated {
626 if let Some(at) = &self.access_token {
627 map.insert(
628 header::AUTHORIZATION,
629 HeaderValue::from_str(&format!("Bearer {at}"))?,
630 );
631 }
632 }
633 map.insert(
634 COMPATIBILITY_HEADER,
635 HeaderValue::from_str(&self.compatibility_date)?,
636 );
637 map
638 };
639 let url = format!("{}{endpoint}", self.base_api_url);
640 let mut req_builder = self
641 .client
642 .request(Method::from_str(method)?, &url)
643 .headers(headers)
644 .query(query.unwrap_or(&[]));
645 req_builder = match body {
646 Some(b) => req_builder.body(b.to_owned()),
647 None => req_builder,
648 };
649 let req = req_builder.build()?;
650 let resp = self.client.execute(req).await?;
651 self.process_response_headers(resp.headers()).await?;
652 if !resp.status().is_success() {
653 return Err(Self::status_error(resp.status().as_u16(), resp.headers()));
654 }
655 let text = resp.text().await?;
656 let data: T = serde_json::from_str(&text)?;
657 Ok(data)
658 }
659
660 pub async fn try_get_endpoint_for_op_id(&mut self, op_id: &str) -> EsiResult<String> {
695 if self.spec.is_none() {
696 debug!("Spec is `None`; must fetch before looking up op_id");
697 self.update_spec().await?;
698 }
699 self.get_endpoint_for_op_id(op_id)
700 }
701
702 pub fn get_endpoint_for_op_id(&self, op_id: &str) -> EsiResult<String> {
729 if self.spec.is_none() {
730 return Err(EsiError::EmptySpec);
731 }
732 if let Some(path) = self.op_index.get(op_id) {
733 return Ok(path.clone());
734 }
735 if let Some(new_id) = legacy::openapi_id_for(op_id) {
736 warn!(
737 "operationId '{op_id}' is a deprecated Swagger ID; use '{new_id}' instead (legacy IDs will be removed in 0.2.0)"
738 );
739 if let Some(path) = self.op_index.get(new_id) {
740 return Ok(path.clone());
741 }
742 }
743 Err(EsiError::UnknownOperationID(op_id.to_owned()))
744 }
745
746 fn status_error(status: u16, headers: &HeaderMap) -> EsiError {
748 if status == 429 {
749 let header_str = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
750 let group = header_str(RATE_LIMIT_GROUP_HEADER).map(str::to_owned);
751 let retry_after_secs =
752 header_str(header::RETRY_AFTER.as_str()).and_then(|v| v.trim().parse::<u64>().ok());
753 warn!("Rate limited by ESI (group {group:?}); retry after {retry_after_secs:?}s");
754 return EsiError::RateLimited {
755 group,
756 retry_after_secs,
757 };
758 }
759 EsiError::InvalidStatusCode(status)
760 }
761
762 async fn process_response_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
764 self.process_error_limit_headers(headers).await?;
765 self.process_rate_limit_headers(headers).await
766 }
767
768 async fn process_rate_limit_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
769 let Some(group) = headers.get(RATE_LIMIT_GROUP_HEADER) else {
770 return Ok(());
771 };
772 let group = group.to_str()?.to_owned();
773 let limit = match headers.get(RATE_LIMIT_LIMIT_HEADER) {
774 Some(v) => v.to_str()?.to_owned(),
775 None => String::new(),
776 };
777 let parse_i64 = |name: &str| -> Result<i64, EsiError> {
778 match headers.get(name) {
779 Some(v) => v
780 .to_str()?
781 .trim()
782 .parse::<i64>()
783 .map_err(|e| EsiError::HeaderParseError(name.into(), e)),
784 None => Ok(0),
785 }
786 };
787 let remaining = parse_i64(RATE_LIMIT_REMAINING_HEADER)?;
788 let used = parse_i64(RATE_LIMIT_USED_HEADER)?;
789 let (max_tokens, window_secs) = parse_rate_limit(&limit);
790 let status = RateLimitStatus {
791 group: group.clone(),
792 limit,
793 max_tokens,
794 window_secs,
795 remaining,
796 used,
797 updated_at_millis: current_time_millis()?,
798 };
799 debug!("Rate limit status: {status:?}");
800 self.rate_limits.write().await.insert(group, status);
801 Ok(())
802 }
803
804 pub async fn rate_limit_status(&self, group: &str) -> Option<RateLimitStatus> {
811 self.rate_limits.read().await.get(group).cloned()
812 }
813
814 pub async fn rate_limit_statuses(&self) -> HashMap<String, RateLimitStatus> {
816 self.rate_limits.read().await.clone()
817 }
818
819 async fn process_error_limit_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
820 match (
821 headers.get(ERROR_LIMIT_REMAIN_HEADER),
822 headers.get(ERROR_LIMIT_RESET_HEADER),
823 ) {
824 (Some(remain_header), Some(reset_header)) => {
825 let remaining_limit = remain_header
826 .to_str()?
827 .parse::<i32>()
828 .map_err(|e| EsiError::HeaderParseError(ERROR_LIMIT_REMAIN_HEADER.into(), e))?;
829 let resets_in = reset_header
830 .to_str()?
831 .parse::<i64>()
832 .map_err(|e| EsiError::HeaderParseError(ERROR_LIMIT_RESET_HEADER.into(), e))?;
833
834 let expires_at_millis = current_time_millis()? + resets_in * 1000;
835
836 self.error_limit_state
837 .write()
838 .await
839 .replace(ErrorLimitState {
840 remaining_limit,
841 expires_at_millis,
842 });
843 Ok(())
844 }
845 _ => Ok(()),
846 }
847 }
848
849 async fn assert_not_error_limited(&self) -> Result<(), EsiError> {
850 match self.is_error_limited().await? {
851 Limited { for_millis } => Err(EsiError::ErrorLimited(for_millis)),
852 NotLimited => Ok(()),
853 }
854 }
855
856 pub async fn is_error_limited(&self) -> Result<ErrorLimitStatus, EsiError> {
860 match &self.error_limit_state.read().await.as_ref() {
861 None => Ok(NotLimited),
862 Some(state) => {
863 if state.remaining_limit > 0 {
864 return Ok(NotLimited);
865 }
866 let remaining_time = state.expires_at_millis - current_time_millis()?;
867 if remaining_time < 0 {
868 return Ok(NotLimited);
869 }
870 Ok(Limited {
871 for_millis: remaining_time,
872 })
873 }
874 }
875 }
876
877 pub fn get_spec(&self) -> Option<&Spec> {
881 self.spec.as_ref()
882 }
883
884 pub fn group_alliance(&self) -> AllianceGroup<'_> {
886 AllianceGroup { esi: self }
887 }
888
889 pub fn group_assets(&self) -> AssetsGroup<'_> {
891 AssetsGroup { esi: self }
892 }
893
894 pub fn group_bookmarks(&self) -> BookmarksGroup<'_> {
896 BookmarksGroup { esi: self }
897 }
898
899 pub fn group_calendar(&self) -> CalendarGroup<'_> {
901 CalendarGroup { esi: self }
902 }
903
904 pub fn group_character(&self) -> CharacterGroup<'_> {
906 CharacterGroup { esi: self }
907 }
908
909 pub fn group_clones(&self) -> ClonesGroup<'_> {
911 ClonesGroup { esi: self }
912 }
913
914 pub fn group_contacts(&self) -> ContactsGroup<'_> {
916 ContactsGroup { esi: self }
917 }
918
919 pub fn group_contracts(&self) -> ContractsGroup<'_> {
921 ContractsGroup { esi: self }
922 }
923
924 pub fn group_corporation(&self) -> CorporationGroup<'_> {
926 CorporationGroup { esi: self }
927 }
928
929 pub fn group_dogma(&self) -> DogmaGroup<'_> {
931 DogmaGroup { esi: self }
932 }
933
934 pub fn group_faction_warfare(&self) -> FactionWarfareGroup<'_> {
936 FactionWarfareGroup { esi: self }
937 }
938
939 pub fn group_fittings(&self) -> FittingsGroup<'_> {
941 FittingsGroup { esi: self }
942 }
943
944 pub fn group_fleets(&self) -> FleetsGroup<'_> {
946 FleetsGroup { esi: self }
947 }
948
949 pub fn group_incursions(&self) -> IncursionsGroup<'_> {
951 IncursionsGroup { esi: self }
952 }
953
954 pub fn group_industry(&self) -> IndustryGroup<'_> {
956 IndustryGroup { esi: self }
957 }
958
959 pub fn group_insurance(&self) -> InsuranceGroup<'_> {
961 InsuranceGroup { esi: self }
962 }
963
964 pub fn group_killmails(&self) -> KillmailsGroup<'_> {
966 KillmailsGroup { esi: self }
967 }
968
969 pub fn group_location(&self) -> LocationGroup<'_> {
971 LocationGroup { esi: self }
972 }
973
974 pub fn group_loyalty(&self) -> LoyaltyGroup<'_> {
976 LoyaltyGroup { esi: self }
977 }
978
979 pub fn group_mail(&self) -> MailGroup<'_> {
981 MailGroup { esi: self }
982 }
983
984 pub fn group_market(&self) -> MarketGroup<'_> {
986 MarketGroup { esi: self }
987 }
988
989 pub fn group_opportunities(&self) -> OpportunitiesGroup<'_> {
991 OpportunitiesGroup { esi: self }
992 }
993
994 pub fn group_planetary_interaction(&self) -> PlanetaryInteractionGroup<'_> {
996 PlanetaryInteractionGroup { esi: self }
997 }
998
999 pub fn group_routes(&self) -> RoutesGroup<'_> {
1001 RoutesGroup { esi: self }
1002 }
1003
1004 pub fn group_search(&self) -> SearchGroup<'_> {
1006 SearchGroup { esi: self }
1007 }
1008
1009 pub fn group_skills(&self) -> SkillsGroup<'_> {
1011 SkillsGroup { esi: self }
1012 }
1013
1014 pub fn group_sovereignty(&self) -> SovereigntyGroup<'_> {
1016 SovereigntyGroup { esi: self }
1017 }
1018
1019 pub fn group_status(&self) -> StatusGroup<'_> {
1021 StatusGroup { esi: self }
1022 }
1023
1024 pub fn group_universe(&self) -> UniverseGroup<'_> {
1026 UniverseGroup { esi: self }
1027 }
1028
1029 pub fn group_user_interface(&self) -> UserInterfaceGroup<'_> {
1031 UserInterfaceGroup { esi: self }
1032 }
1033
1034 pub fn group_wallet(&self) -> WalletGroup<'_> {
1036 WalletGroup { esi: self }
1037 }
1038
1039 pub fn group_wars(&self) -> WarsGroup<'_> {
1041 WarsGroup { esi: self }
1042 }
1043}
1044
1045fn current_time_millis() -> Result<i64, EsiError> {
1047 Ok(SystemTime::now()
1048 .duration_since(UNIX_EPOCH)?
1049 .as_millis()
1050 .try_into()
1051 .expect("i64 overflow for time"))
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::{
1057 parse_rate_limit, AuthenticateResponse, Esi, ERROR_LIMIT_REMAIN_HEADER,
1058 ERROR_LIMIT_RESET_HEADER, RATE_LIMIT_GROUP_HEADER, RATE_LIMIT_LIMIT_HEADER,
1059 RATE_LIMIT_REMAINING_HEADER, RATE_LIMIT_USED_HEADER,
1060 };
1061 use crate::errors::EsiError;
1062 use crate::prelude::EsiBuilder;
1063 use crate::spec::Spec;
1064 use http::{HeaderMap, HeaderValue};
1065 use std::time::Duration;
1066
1067 const FIXTURE: &str = include_str!("../resources/test/openapi.json");
1068
1069 fn esi_with_fixture() -> Esi {
1070 let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
1071 EsiBuilder::new()
1072 .user_agent("Client test, not meant to request")
1073 .spec(Some(spec))
1074 .build()
1075 .unwrap()
1076 }
1077
1078 #[test]
1079 fn test_resolve_openapi_op_id() {
1080 let esi = esi_with_fixture();
1081 assert_eq!(
1082 esi.get_endpoint_for_op_id("GetMarketsRegionIdOrders")
1083 .unwrap(),
1084 "markets/{region_id}/orders"
1085 );
1086 assert_eq!(
1087 esi.get_endpoint_for_op_id("PostUniverseIds").unwrap(),
1088 "universe/ids"
1089 );
1090 }
1091
1092 #[test]
1093 fn test_resolve_legacy_op_id() {
1094 let esi = esi_with_fixture();
1095 assert_eq!(
1096 esi.get_endpoint_for_op_id("get_markets_region_id_orders")
1097 .unwrap(),
1098 "markets/{region_id}/orders"
1099 );
1100 assert_eq!(
1101 esi.get_endpoint_for_op_id("get_characters_character_id")
1102 .unwrap(),
1103 "characters/{character_id}"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_all_legacy_ids_resolve() {
1109 let esi = esi_with_fixture();
1110 for (legacy, openapi) in crate::legacy::LEGACY_OP_IDS {
1111 esi.get_endpoint_for_op_id(openapi)
1112 .unwrap_or_else(|_| panic!("{openapi} (from {legacy}) missing from spec"));
1113 }
1114 }
1115
1116 #[test]
1117 fn test_resolve_unknown_op_id() {
1118 let esi = esi_with_fixture();
1119 match esi.get_endpoint_for_op_id("GetNothingHere") {
1120 Err(EsiError::UnknownOperationID(id)) => assert_eq!(id, "GetNothingHere"),
1121 other => panic!("Unexpected result: {other:?}"),
1122 }
1123 }
1124
1125 #[test]
1126 fn test_resolve_without_spec() {
1127 let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1128 assert!(matches!(
1129 esi.get_endpoint_for_op_id("GetMarketsPrices"),
1130 Err(EsiError::EmptySpec)
1131 ));
1132 }
1133
1134 #[test]
1135 fn test_parse_rate_limit() {
1136 assert_eq!(parse_rate_limit("150/15m"), (Some(150), Some(900)));
1137 assert_eq!(parse_rate_limit("20/1h"), (Some(20), Some(3600)));
1138 assert_eq!(parse_rate_limit("300/30s"), (Some(300), Some(30)));
1139 assert_eq!(parse_rate_limit("10/5x"), (Some(10), None));
1140 assert_eq!(parse_rate_limit("garbage"), (None, None));
1141 }
1142
1143 #[tokio::test]
1144 async fn test_rate_limit_headers() {
1145 let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1146 let mut headers = HeaderMap::new();
1147 headers.append(RATE_LIMIT_GROUP_HEADER, HeaderValue::from_static("market"));
1148 headers.append(RATE_LIMIT_LIMIT_HEADER, HeaderValue::from_static("150/15m"));
1149 headers.append(RATE_LIMIT_REMAINING_HEADER, HeaderValue::from_static("148"));
1150 headers.append(RATE_LIMIT_USED_HEADER, HeaderValue::from_static("2"));
1151 esi.process_response_headers(&headers)
1152 .await
1153 .expect("Should parse");
1154 let status = esi.rate_limit_status("market").await.expect("recorded");
1155 assert_eq!(status.limit, "150/15m");
1156 assert_eq!(status.max_tokens, Some(150));
1157 assert_eq!(status.window_secs, Some(900));
1158 assert_eq!(status.remaining, 148);
1159 assert_eq!(status.used, 2);
1160 assert!(esi.rate_limit_status("other").await.is_none());
1161 assert_eq!(esi.rate_limit_statuses().await.len(), 1);
1162 }
1163
1164 #[tokio::test]
1165 async fn test_no_rate_limit_headers() {
1166 let esi = EsiBuilder::new().user_agent("test").build().unwrap();
1167 esi.process_response_headers(&HeaderMap::new())
1168 .await
1169 .expect("Should parse");
1170 assert!(esi.rate_limit_statuses().await.is_empty());
1171 }
1172
1173 #[test]
1174 fn test_status_error_429() {
1175 let mut headers = HeaderMap::new();
1176 headers.append(RATE_LIMIT_GROUP_HEADER, HeaderValue::from_static("market"));
1177 headers.append(http::header::RETRY_AFTER, HeaderValue::from_static("12"));
1178 match Esi::status_error(429, &headers) {
1179 EsiError::RateLimited {
1180 group,
1181 retry_after_secs,
1182 } => {
1183 assert_eq!(group.as_deref(), Some("market"));
1184 assert_eq!(retry_after_secs, Some(12));
1185 }
1186 other => panic!("Unexpected error: {other}"),
1187 }
1188 assert!(matches!(
1189 Esi::status_error(404, &headers),
1190 EsiError::InvalidStatusCode(404)
1191 ));
1192 }
1193
1194 #[test]
1195 fn test_authenticateresponse_deserialize() {
1196 let source = r#"{
1197 "access_token": "abc",
1198 "expires_in": 1000,
1199 "refresh_token": "def"
1200 }"#;
1201 let data: AuthenticateResponse = serde_json::from_str(source).unwrap();
1202
1203 assert_eq!(data.access_token, "abc");
1204 assert_eq!(data.expires_in, 1000);
1205 assert_eq!(data.refresh_token, Some("def".to_owned()));
1206 }
1207
1208 #[test]
1209 fn test_authenticateresponse_deserialize_no_refresh_token() {
1210 let source = r#"{
1211 "access_token": "abc",
1212 "expires_in": 1000,
1213 "refresh_token": null
1214 }"#;
1215 let data: AuthenticateResponse = serde_json::from_str(source).unwrap();
1216
1217 assert_eq!(data.access_token, "abc");
1218 assert_eq!(data.expires_in, 1000);
1219 assert_eq!(data.refresh_token, None);
1220 }
1221
1222 #[tokio::test]
1223 async fn test_error_limit_header_not_limited() {
1224 let esi = EsiBuilder::default()
1225 .user_agent("Client test, not meant to request")
1226 .build()
1227 .unwrap();
1228 let mut headers = HeaderMap::new();
1229 headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("100"));
1230 headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("5"));
1231 esi.process_error_limit_headers(&headers)
1232 .await
1233 .expect("Should parse");
1234 esi.assert_not_error_limited()
1235 .await
1236 .expect("Should not be error limited");
1237 }
1238
1239 #[tokio::test]
1240 async fn test_error_limit_header_limited() {
1241 let esi = EsiBuilder::default()
1242 .user_agent("Client test, not meant to request")
1243 .build()
1244 .unwrap();
1245 let mut headers = HeaderMap::new();
1246 headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("0"));
1247 headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("2"));
1248 esi.process_error_limit_headers(&headers)
1249 .await
1250 .expect("Should parse");
1251 let err = esi
1252 .assert_not_error_limited()
1253 .await
1254 .expect_err("Should be limited");
1255 match err {
1256 EsiError::ErrorLimited(millis) => {
1257 assert!(millis <= 2000)
1258 }
1259 _ => panic!("Unexpected error: {}", err),
1260 }
1261 }
1262
1263 #[tokio::test]
1264 #[ignore] async fn test_error_limit_expired_limit() {
1266 let esi = EsiBuilder::default()
1267 .user_agent("Client test, not meant to request")
1268 .build()
1269 .unwrap();
1270 let mut headers = HeaderMap::new();
1271 headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("0"));
1272 headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("2"));
1273 esi.process_error_limit_headers(&headers)
1274 .await
1275 .expect("Should parse");
1276 println!("Waiting 2 seconds ..");
1277 tokio::time::sleep(Duration::from_millis(2050)).await;
1278 esi.assert_not_error_limited()
1279 .await
1280 .expect("Should not be error limited");
1281 }
1282}