use crate::{
client::ErrorLimitStatus::{Limited, NotLimited},
groups::*,
legacy,
pkce::{self, PkceVerifier},
prelude::*,
spec::Spec,
};
use base64::engine::{general_purpose::STANDARD as base64, Engine};
use log::{debug, error, warn};
#[cfg(feature = "random_state")]
use rand::{distr::Alphanumeric, RngExt};
use reqwest::{
header::{self, HeaderMap, HeaderValue},
Client, Method,
};
use serde::de::DeserializeOwned;
use std::{
collections::HashMap,
str::FromStr,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use tokio::sync::RwLock;
const BASE_URL: &str = "https://esi.evetech.net/";
const AUTHORIZE_URL: &str = "https://login.eveonline.com/v2/oauth/authorize";
const TOKEN_URL: &str = "https://login.eveonline.com/v2/oauth/token";
const SPEC_URL: &str = "https://esi.evetech.net/meta/openapi.json";
const ERROR_LIMIT_REMAIN_HEADER: &str = "x-esi-error-limit-remain";
const ERROR_LIMIT_RESET_HEADER: &str = "x-esi-error-limit-reset";
const RATE_LIMIT_GROUP_HEADER: &str = "x-ratelimit-group";
const RATE_LIMIT_LIMIT_HEADER: &str = "x-ratelimit-limit";
const RATE_LIMIT_REMAINING_HEADER: &str = "x-ratelimit-remaining";
const RATE_LIMIT_USED_HEADER: &str = "x-ratelimit-used";
static COMPATIBILITY_HEADER: &str = "X-Compatibility-Date";
pub const COMPATIBILITY_DATE_DEFAULT: &str = "2026-08-18";
#[derive(Debug, Deserialize)]
struct AuthenticateResponse {
access_token: String,
expires_in: u64,
refresh_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RefreshTokenAuthenticateResponse {
access_token: String,
expires_in: u64,
refresh_token: String,
}
#[derive(Copy, Clone, Debug)]
struct ErrorLimitState {
remaining_limit: i32,
expires_at_millis: i64,
}
#[derive(Copy, Clone, Debug)]
pub enum ErrorLimitStatus {
Limited {
for_millis: i64,
},
NotLimited,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RateLimitStatus {
pub group: String,
pub limit: String,
pub max_tokens: Option<u64>,
pub window_secs: Option<u64>,
pub remaining: i64,
pub used: i64,
pub updated_at_millis: i64,
}
fn parse_rate_limit(value: &str) -> (Option<u64>, Option<u64>) {
let Some((tokens, window)) = value.trim().split_once('/') else {
return (value.trim().parse().ok(), None);
};
let tokens = tokens.trim().parse().ok();
let window = window.trim();
let split = window
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(window.len());
let (amount, unit) = window.split_at(split);
let amount: Option<u64> = amount.parse().ok();
let multiplier = match unit {
"" | "s" => Some(1),
"m" => Some(60),
"h" => Some(3600),
"d" => Some(86_400),
_ => None,
};
let window_secs = amount.zip(multiplier).map(|(a, m)| a * m);
(tokens, window_secs)
}
#[derive(Debug, PartialEq, Eq)]
pub enum RequestType {
Public,
Authenticated,
}
pub struct AuthenticationInformation {
pub authorization_url: String,
pub state: String,
pub pkce_verifier: Option<PkceVerifier>,
}
#[derive(Clone, Debug)]
pub struct Esi {
pub(crate) compatibility_date: String,
pub(crate) client_id: Option<String>,
pub(crate) client_secret: Option<String>,
pub(crate) callback_url: Option<String>,
pub(crate) base_api_url: String,
pub(crate) authorize_url: String,
pub(crate) token_url: String,
pub(crate) spec_url: String,
pub(crate) scope: String,
pub(crate) application_auth: bool,
pub access_token: Option<String>,
pub access_expiration: Option<i64>,
pub refresh_token: Option<String>,
pub(crate) client: Client,
pub(crate) spec: Option<Spec>,
op_index: HashMap<String, String>,
error_limit_state: Arc<RwLock<Option<ErrorLimitState>>>,
rate_limits: Arc<RwLock<HashMap<String, RateLimitStatus>>>,
}
impl Esi {
pub(crate) fn from_builder(builder: EsiBuilder) -> EsiResult<Self> {
let client = builder.construct_client()?;
let compatibility_date = builder
.compatibility_date
.unwrap_or_else(|| COMPATIBILITY_DATE_DEFAULT.to_owned());
let op_index = builder
.spec
.as_ref()
.map(Spec::operation_index)
.unwrap_or_default();
let e = Esi {
compatibility_date: compatibility_date.clone(),
client_id: builder.client_id,
client_secret: builder.client_secret,
callback_url: builder.callback_url,
base_api_url: builder.base_api_url.unwrap_or(BASE_URL.to_string()),
authorize_url: builder.authorize_url.unwrap_or(AUTHORIZE_URL.to_string()),
token_url: builder.token_url.unwrap_or(TOKEN_URL.to_string()),
spec_url: builder.spec_url.unwrap_or(SPEC_URL.to_string()),
scope: builder.scope.unwrap_or_else(|| "".to_owned()),
application_auth: builder.application_auth.unwrap_or(false),
access_token: builder.access_token,
access_expiration: builder.access_expiration,
refresh_token: builder.refresh_token,
client,
spec: builder.spec,
op_index,
error_limit_state: Arc::new(RwLock::new(None)),
rate_limits: Arc::new(RwLock::new(HashMap::new())),
};
Ok(e)
}
pub async fn update_spec(&mut self) -> EsiResult<()> {
debug!(
"Updating spec with compatibility date {}",
self.compatibility_date
);
self.assert_not_error_limited().await?;
let resp = self
.client
.get(&self.spec_url)
.header(
COMPATIBILITY_HEADER,
HeaderValue::from_str(&self.compatibility_date)?,
)
.send()
.await?;
self.process_response_headers(resp.headers()).await?;
if !resp.status().is_success() {
error!("Got status {} when requesting spec", resp.status());
return Err(Self::status_error(resp.status().as_u16(), resp.headers()));
}
let data: Spec = resp.json().await?;
self.op_index = data.operation_index();
self.spec = Some(data);
Ok(())
}
fn check_client_info(&self) -> EsiResult<()> {
for (name, value) in &[
("client_id", &self.client_id),
("callback_url", &self.callback_url),
] {
if value.is_none() {
return Err(EsiError::EmptyClientValue(name.to_string()));
}
}
if self.client_secret.is_none() {
if !self.application_auth {
return Err(EsiError::MissingAuthenticationFlowInformation);
}
} else if self.application_auth {
return Err(EsiError::MissingAuthenticationFlowInformation);
}
Ok(())
}
pub fn get_authorize_url(&self) -> EsiResult<AuthenticationInformation> {
self.check_client_info()?;
#[cfg(feature = "random_state")]
let state = rand::rng()
.sample_iter(&Alphanumeric)
.take(10)
.map(char::from)
.collect();
#[cfg(not(feature = "random_state"))]
let state = "esi_openapi_unused".to_string();
let mut url = format!(
"{}?response_type=code&redirect_uri={}&client_id={}&scope={}&state={state}",
self.authorize_url,
self.callback_url.as_ref().unwrap(),
self.client_id.as_ref().unwrap(),
self.scope
);
let mut pkce_verifier = None;
if self.client_secret.is_none() && self.application_auth {
let pkce = pkce::generate()?;
pkce_verifier = Some(pkce.verifier);
url = format!(
"{}&code_challenge={}&code_challenge_method=S256",
url, pkce.challenge
)
}
Ok(AuthenticationInformation {
authorization_url: url,
state,
pkce_verifier,
})
}
fn get_auth_headers(&self) -> EsiResult<HeaderMap> {
self.check_client_info()?;
let mut map = HeaderMap::new();
if let Some(ref secret) = self.client_secret {
let value = base64
.encode(format!("{}:{secret}", self.client_id.as_ref().unwrap()))
.replace(['\n', ' '], "");
map.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Basic {value}"))?,
);
}
map.insert(
header::HOST,
HeaderValue::from_static("login.eveonline.com"),
);
Ok(map)
}
pub async fn authenticate(
&mut self,
code: &str,
pkce_verifier: Option<PkceVerifier>,
) -> EsiResult<Option<TokenClaims>> {
debug!("Authenticating with code {code}");
self.assert_not_error_limited().await?;
let mut body = HashMap::from([("grant_type", "authorization_code"), ("code", code)]);
if self.application_auth {
let option = self.client_id.as_ref();
body.insert("client_id", option.unwrap());
body.insert("code_verifier", pkce_verifier.as_ref().unwrap());
}
let resp = self
.client
.post(&self.token_url)
.headers(self.get_auth_headers()?)
.form(&body)
.send()
.await?;
if resp.status() != 200 {
warn!(
"Got status {} when making call to authenticate",
resp.status()
);
return Err(EsiError::InvalidStatusCode(resp.status().as_u16()));
}
self.process_error_limit_headers(resp.headers()).await?;
let data: AuthenticateResponse = resp.json().await?;
#[allow(unused_variables)]
let claim_data: Option<TokenClaims> = None;
#[cfg(feature = "validate_jwt")]
let claim_data = Some(
crate::jwt_util::validate_jwt(
&self.client,
&data.access_token,
self.client_id.as_ref().unwrap(),
)
.await?,
);
self.access_token = Some(data.access_token);
self.access_expiration = Some((data.expires_in as i64 * 1_000) + current_time_millis()?);
self.refresh_token = data.refresh_token;
Ok(claim_data)
}
pub async fn use_refresh_token(&mut self, refresh_token: &str) -> EsiResult<()> {
self.refresh_access_token(Some(refresh_token)).await?;
Ok(())
}
pub async fn refresh_access_token(&mut self, refresh_token: Option<&str>) -> EsiResult<()> {
self.assert_not_error_limited().await?;
let token = if let Some(token) = refresh_token {
token.to_string()
} else if let Some(token) = self.refresh_token.clone() {
token
} else {
return Err(EsiError::NoRefreshTokenAvailable);
};
debug!("Authenticating with refresh token");
let mut body = HashMap::from([("grant_type", "refresh_token"), ("refresh_token", &token)]);
if self.application_auth {
let option = self.client_id.as_ref();
body.insert("client_id", option.unwrap());
}
let resp = self
.client
.post(&self.token_url)
.headers(self.get_auth_headers()?)
.form(&body)
.send()
.await?;
self.process_error_limit_headers(resp.headers()).await?;
if resp.status() != 200 {
warn!(
"Got status {} when making call to authenticate via a refresh token",
resp.status()
);
return Err(EsiError::InvalidStatusCode(resp.status().as_u16()));
}
let data: RefreshTokenAuthenticateResponse = resp.json().await?;
self.access_token = Some(data.access_token);
self.access_expiration = Some((data.expires_in as i64 * 1_000) + current_time_millis()?);
self.refresh_token = Some(data.refresh_token);
Ok(())
}
pub async fn query<T: DeserializeOwned>(
&self,
method: &str,
request_type: RequestType,
endpoint: &str,
query: Option<&[(&str, &str)]>,
body: Option<&str>,
) -> EsiResult<T> {
debug!("Making {request_type:?} {method} request to {endpoint} with query: {query:?}");
self.assert_not_error_limited().await?;
if request_type == RequestType::Authenticated {
if self.access_token.is_none() {
return Err(EsiError::MissingAuthentication);
}
if self.access_expiration.unwrap() < current_time_millis()? {
return Err(EsiError::AccessTokenExpired);
}
}
let headers = {
let mut map = HeaderMap::new();
if request_type == RequestType::Authenticated {
if let Some(at) = &self.access_token {
map.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {at}"))?,
);
}
}
map.insert(
COMPATIBILITY_HEADER,
HeaderValue::from_str(&self.compatibility_date)?,
);
map
};
let url = format!("{}{endpoint}", self.base_api_url);
let mut req_builder = self
.client
.request(Method::from_str(method)?, &url)
.headers(headers)
.query(query.unwrap_or(&[]));
req_builder = match body {
Some(b) => req_builder.body(b.to_owned()),
None => req_builder,
};
let req = req_builder.build()?;
let resp = self.client.execute(req).await?;
self.process_response_headers(resp.headers()).await?;
if !resp.status().is_success() {
return Err(Self::status_error(resp.status().as_u16(), resp.headers()));
}
let text = resp.text().await?;
let data: T = serde_json::from_str(&text)?;
Ok(data)
}
pub async fn try_get_endpoint_for_op_id(&mut self, op_id: &str) -> EsiResult<String> {
if self.spec.is_none() {
debug!("Spec is `None`; must fetch before looking up op_id");
self.update_spec().await?;
}
self.get_endpoint_for_op_id(op_id)
}
pub fn get_endpoint_for_op_id(&self, op_id: &str) -> EsiResult<String> {
if self.spec.is_none() {
return Err(EsiError::EmptySpec);
}
if let Some(path) = self.op_index.get(op_id) {
return Ok(path.clone());
}
if let Some(new_id) = legacy::openapi_id_for(op_id) {
warn!(
"operationId '{op_id}' is a deprecated Swagger ID; use '{new_id}' instead (legacy IDs will be removed in 0.2.0)"
);
if let Some(path) = self.op_index.get(new_id) {
return Ok(path.clone());
}
}
Err(EsiError::UnknownOperationID(op_id.to_owned()))
}
fn status_error(status: u16, headers: &HeaderMap) -> EsiError {
if status == 429 {
let header_str = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
let group = header_str(RATE_LIMIT_GROUP_HEADER).map(str::to_owned);
let retry_after_secs =
header_str(header::RETRY_AFTER.as_str()).and_then(|v| v.trim().parse::<u64>().ok());
warn!("Rate limited by ESI (group {group:?}); retry after {retry_after_secs:?}s");
return EsiError::RateLimited {
group,
retry_after_secs,
};
}
EsiError::InvalidStatusCode(status)
}
async fn process_response_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
self.process_error_limit_headers(headers).await?;
self.process_rate_limit_headers(headers).await
}
async fn process_rate_limit_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
let Some(group) = headers.get(RATE_LIMIT_GROUP_HEADER) else {
return Ok(());
};
let group = group.to_str()?.to_owned();
let limit = match headers.get(RATE_LIMIT_LIMIT_HEADER) {
Some(v) => v.to_str()?.to_owned(),
None => String::new(),
};
let parse_i64 = |name: &str| -> Result<i64, EsiError> {
match headers.get(name) {
Some(v) => v
.to_str()?
.trim()
.parse::<i64>()
.map_err(|e| EsiError::HeaderParseError(name.into(), e)),
None => Ok(0),
}
};
let remaining = parse_i64(RATE_LIMIT_REMAINING_HEADER)?;
let used = parse_i64(RATE_LIMIT_USED_HEADER)?;
let (max_tokens, window_secs) = parse_rate_limit(&limit);
let status = RateLimitStatus {
group: group.clone(),
limit,
max_tokens,
window_secs,
remaining,
used,
updated_at_millis: current_time_millis()?,
};
debug!("Rate limit status: {status:?}");
self.rate_limits.write().await.insert(group, status);
Ok(())
}
pub async fn rate_limit_status(&self, group: &str) -> Option<RateLimitStatus> {
self.rate_limits.read().await.get(group).cloned()
}
pub async fn rate_limit_statuses(&self) -> HashMap<String, RateLimitStatus> {
self.rate_limits.read().await.clone()
}
async fn process_error_limit_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> {
match (
headers.get(ERROR_LIMIT_REMAIN_HEADER),
headers.get(ERROR_LIMIT_RESET_HEADER),
) {
(Some(remain_header), Some(reset_header)) => {
let remaining_limit = remain_header
.to_str()?
.parse::<i32>()
.map_err(|e| EsiError::HeaderParseError(ERROR_LIMIT_REMAIN_HEADER.into(), e))?;
let resets_in = reset_header
.to_str()?
.parse::<i64>()
.map_err(|e| EsiError::HeaderParseError(ERROR_LIMIT_RESET_HEADER.into(), e))?;
let expires_at_millis = current_time_millis()? + resets_in * 1000;
self.error_limit_state
.write()
.await
.replace(ErrorLimitState {
remaining_limit,
expires_at_millis,
});
Ok(())
}
_ => Ok(()),
}
}
async fn assert_not_error_limited(&self) -> Result<(), EsiError> {
match self.is_error_limited().await? {
Limited { for_millis } => Err(EsiError::ErrorLimited(for_millis)),
NotLimited => Ok(()),
}
}
pub async fn is_error_limited(&self) -> Result<ErrorLimitStatus, EsiError> {
match &self.error_limit_state.read().await.as_ref() {
None => Ok(NotLimited),
Some(state) => {
if state.remaining_limit > 0 {
return Ok(NotLimited);
}
let remaining_time = state.expires_at_millis - current_time_millis()?;
if remaining_time < 0 {
return Ok(NotLimited);
}
Ok(Limited {
for_millis: remaining_time,
})
}
}
}
pub fn get_spec(&self) -> Option<&Spec> {
self.spec.as_ref()
}
pub fn group_alliance(&self) -> AllianceGroup<'_> {
AllianceGroup { esi: self }
}
pub fn group_assets(&self) -> AssetsGroup<'_> {
AssetsGroup { esi: self }
}
pub fn group_bookmarks(&self) -> BookmarksGroup<'_> {
BookmarksGroup { esi: self }
}
pub fn group_calendar(&self) -> CalendarGroup<'_> {
CalendarGroup { esi: self }
}
pub fn group_character(&self) -> CharacterGroup<'_> {
CharacterGroup { esi: self }
}
pub fn group_clones(&self) -> ClonesGroup<'_> {
ClonesGroup { esi: self }
}
pub fn group_contacts(&self) -> ContactsGroup<'_> {
ContactsGroup { esi: self }
}
pub fn group_contracts(&self) -> ContractsGroup<'_> {
ContractsGroup { esi: self }
}
pub fn group_corporation(&self) -> CorporationGroup<'_> {
CorporationGroup { esi: self }
}
pub fn group_dogma(&self) -> DogmaGroup<'_> {
DogmaGroup { esi: self }
}
pub fn group_faction_warfare(&self) -> FactionWarfareGroup<'_> {
FactionWarfareGroup { esi: self }
}
pub fn group_fittings(&self) -> FittingsGroup<'_> {
FittingsGroup { esi: self }
}
pub fn group_fleets(&self) -> FleetsGroup<'_> {
FleetsGroup { esi: self }
}
pub fn group_incursions(&self) -> IncursionsGroup<'_> {
IncursionsGroup { esi: self }
}
pub fn group_industry(&self) -> IndustryGroup<'_> {
IndustryGroup { esi: self }
}
pub fn group_insurance(&self) -> InsuranceGroup<'_> {
InsuranceGroup { esi: self }
}
pub fn group_killmails(&self) -> KillmailsGroup<'_> {
KillmailsGroup { esi: self }
}
pub fn group_location(&self) -> LocationGroup<'_> {
LocationGroup { esi: self }
}
pub fn group_loyalty(&self) -> LoyaltyGroup<'_> {
LoyaltyGroup { esi: self }
}
pub fn group_mail(&self) -> MailGroup<'_> {
MailGroup { esi: self }
}
pub fn group_market(&self) -> MarketGroup<'_> {
MarketGroup { esi: self }
}
pub fn group_opportunities(&self) -> OpportunitiesGroup<'_> {
OpportunitiesGroup { esi: self }
}
pub fn group_planetary_interaction(&self) -> PlanetaryInteractionGroup<'_> {
PlanetaryInteractionGroup { esi: self }
}
pub fn group_routes(&self) -> RoutesGroup<'_> {
RoutesGroup { esi: self }
}
pub fn group_search(&self) -> SearchGroup<'_> {
SearchGroup { esi: self }
}
pub fn group_skills(&self) -> SkillsGroup<'_> {
SkillsGroup { esi: self }
}
pub fn group_sovereignty(&self) -> SovereigntyGroup<'_> {
SovereigntyGroup { esi: self }
}
pub fn group_status(&self) -> StatusGroup<'_> {
StatusGroup { esi: self }
}
pub fn group_universe(&self) -> UniverseGroup<'_> {
UniverseGroup { esi: self }
}
pub fn group_user_interface(&self) -> UserInterfaceGroup<'_> {
UserInterfaceGroup { esi: self }
}
pub fn group_wallet(&self) -> WalletGroup<'_> {
WalletGroup { esi: self }
}
pub fn group_wars(&self) -> WarsGroup<'_> {
WarsGroup { esi: self }
}
}
fn current_time_millis() -> Result<i64, EsiError> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)?
.as_millis()
.try_into()
.expect("i64 overflow for time"))
}
#[cfg(test)]
mod tests {
use super::{
parse_rate_limit, AuthenticateResponse, Esi, ERROR_LIMIT_REMAIN_HEADER,
ERROR_LIMIT_RESET_HEADER, RATE_LIMIT_GROUP_HEADER, RATE_LIMIT_LIMIT_HEADER,
RATE_LIMIT_REMAINING_HEADER, RATE_LIMIT_USED_HEADER,
};
use crate::errors::EsiError;
use crate::prelude::EsiBuilder;
use crate::spec::Spec;
use http::{HeaderMap, HeaderValue};
use std::time::Duration;
const FIXTURE: &str = include_str!("../resources/test/openapi.json");
fn esi_with_fixture() -> Esi {
let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
EsiBuilder::new()
.user_agent("Client test, not meant to request")
.spec(Some(spec))
.build()
.unwrap()
}
#[test]
fn test_resolve_openapi_op_id() {
let esi = esi_with_fixture();
assert_eq!(
esi.get_endpoint_for_op_id("GetMarketsRegionIdOrders")
.unwrap(),
"markets/{region_id}/orders"
);
assert_eq!(
esi.get_endpoint_for_op_id("PostUniverseIds").unwrap(),
"universe/ids"
);
}
#[test]
fn test_resolve_legacy_op_id() {
let esi = esi_with_fixture();
assert_eq!(
esi.get_endpoint_for_op_id("get_markets_region_id_orders")
.unwrap(),
"markets/{region_id}/orders"
);
assert_eq!(
esi.get_endpoint_for_op_id("get_characters_character_id")
.unwrap(),
"characters/{character_id}"
);
}
#[test]
fn test_all_legacy_ids_resolve() {
let esi = esi_with_fixture();
for (legacy, openapi) in crate::legacy::LEGACY_OP_IDS {
esi.get_endpoint_for_op_id(openapi)
.unwrap_or_else(|_| panic!("{openapi} (from {legacy}) missing from spec"));
}
}
#[test]
fn test_resolve_unknown_op_id() {
let esi = esi_with_fixture();
match esi.get_endpoint_for_op_id("GetNothingHere") {
Err(EsiError::UnknownOperationID(id)) => assert_eq!(id, "GetNothingHere"),
other => panic!("Unexpected result: {other:?}"),
}
}
#[test]
fn test_resolve_without_spec() {
let esi = EsiBuilder::new().user_agent("test").build().unwrap();
assert!(matches!(
esi.get_endpoint_for_op_id("GetMarketsPrices"),
Err(EsiError::EmptySpec)
));
}
#[test]
fn test_parse_rate_limit() {
assert_eq!(parse_rate_limit("150/15m"), (Some(150), Some(900)));
assert_eq!(parse_rate_limit("20/1h"), (Some(20), Some(3600)));
assert_eq!(parse_rate_limit("300/30s"), (Some(300), Some(30)));
assert_eq!(parse_rate_limit("10/5x"), (Some(10), None));
assert_eq!(parse_rate_limit("garbage"), (None, None));
}
#[tokio::test]
async fn test_rate_limit_headers() {
let esi = EsiBuilder::new().user_agent("test").build().unwrap();
let mut headers = HeaderMap::new();
headers.append(RATE_LIMIT_GROUP_HEADER, HeaderValue::from_static("market"));
headers.append(RATE_LIMIT_LIMIT_HEADER, HeaderValue::from_static("150/15m"));
headers.append(RATE_LIMIT_REMAINING_HEADER, HeaderValue::from_static("148"));
headers.append(RATE_LIMIT_USED_HEADER, HeaderValue::from_static("2"));
esi.process_response_headers(&headers)
.await
.expect("Should parse");
let status = esi.rate_limit_status("market").await.expect("recorded");
assert_eq!(status.limit, "150/15m");
assert_eq!(status.max_tokens, Some(150));
assert_eq!(status.window_secs, Some(900));
assert_eq!(status.remaining, 148);
assert_eq!(status.used, 2);
assert!(esi.rate_limit_status("other").await.is_none());
assert_eq!(esi.rate_limit_statuses().await.len(), 1);
}
#[tokio::test]
async fn test_no_rate_limit_headers() {
let esi = EsiBuilder::new().user_agent("test").build().unwrap();
esi.process_response_headers(&HeaderMap::new())
.await
.expect("Should parse");
assert!(esi.rate_limit_statuses().await.is_empty());
}
#[test]
fn test_status_error_429() {
let mut headers = HeaderMap::new();
headers.append(RATE_LIMIT_GROUP_HEADER, HeaderValue::from_static("market"));
headers.append(http::header::RETRY_AFTER, HeaderValue::from_static("12"));
match Esi::status_error(429, &headers) {
EsiError::RateLimited {
group,
retry_after_secs,
} => {
assert_eq!(group.as_deref(), Some("market"));
assert_eq!(retry_after_secs, Some(12));
}
other => panic!("Unexpected error: {other}"),
}
assert!(matches!(
Esi::status_error(404, &headers),
EsiError::InvalidStatusCode(404)
));
}
#[test]
fn test_authenticateresponse_deserialize() {
let source = r#"{
"access_token": "abc",
"expires_in": 1000,
"refresh_token": "def"
}"#;
let data: AuthenticateResponse = serde_json::from_str(source).unwrap();
assert_eq!(data.access_token, "abc");
assert_eq!(data.expires_in, 1000);
assert_eq!(data.refresh_token, Some("def".to_owned()));
}
#[test]
fn test_authenticateresponse_deserialize_no_refresh_token() {
let source = r#"{
"access_token": "abc",
"expires_in": 1000,
"refresh_token": null
}"#;
let data: AuthenticateResponse = serde_json::from_str(source).unwrap();
assert_eq!(data.access_token, "abc");
assert_eq!(data.expires_in, 1000);
assert_eq!(data.refresh_token, None);
}
#[tokio::test]
async fn test_error_limit_header_not_limited() {
let esi = EsiBuilder::default()
.user_agent("Client test, not meant to request")
.build()
.unwrap();
let mut headers = HeaderMap::new();
headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("100"));
headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("5"));
esi.process_error_limit_headers(&headers)
.await
.expect("Should parse");
esi.assert_not_error_limited()
.await
.expect("Should not be error limited");
}
#[tokio::test]
async fn test_error_limit_header_limited() {
let esi = EsiBuilder::default()
.user_agent("Client test, not meant to request")
.build()
.unwrap();
let mut headers = HeaderMap::new();
headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("0"));
headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("2"));
esi.process_error_limit_headers(&headers)
.await
.expect("Should parse");
let err = esi
.assert_not_error_limited()
.await
.expect_err("Should be limited");
match err {
EsiError::ErrorLimited(millis) => {
assert!(millis <= 2000)
}
_ => panic!("Unexpected error: {}", err),
}
}
#[tokio::test]
#[ignore] async fn test_error_limit_expired_limit() {
let esi = EsiBuilder::default()
.user_agent("Client test, not meant to request")
.build()
.unwrap();
let mut headers = HeaderMap::new();
headers.append(ERROR_LIMIT_REMAIN_HEADER, HeaderValue::from_static("0"));
headers.append(ERROR_LIMIT_RESET_HEADER, HeaderValue::from_static("2"));
esi.process_error_limit_headers(&headers)
.await
.expect("Should parse");
println!("Waiting 2 seconds ..");
tokio::time::sleep(Duration::from_millis(2050)).await;
esi.assert_not_error_limited()
.await
.expect("Should not be error limited");
}
}