eversal_esi/
lib.rs

1mod alliance;
2pub use alliance::*;
3mod assets;
4pub use assets::*;
5mod auth;
6pub use auth::*;
7mod character;
8pub use character::*;
9mod corporation;
10pub use corporation::*;
11mod killmails;
12pub use killmails::*;
13mod location;
14pub use location::*;
15mod market;
16pub use market::*;
17mod scope;
18pub use scope::Scope;
19mod error;
20pub use error::Error;
21mod universe;
22pub use universe::*;
23
24pub type EsiResult<T> = Result<T, Error>;
25
26use oauth2::{
27  basic::{
28    BasicClient,
29    BasicErrorResponse,
30    BasicRevocationErrorResponse,
31    BasicTokenIntrospectionResponse,
32    BasicTokenResponse,
33  },
34  AuthUrl,
35  ClientId,
36  ClientSecret,
37  EndpointNotSet,
38  EndpointSet,
39  RedirectUrl,
40  StandardRevocableToken,
41  TokenUrl,
42};
43use reqwest::Client;
44use serde::{de::DeserializeOwned, Serialize};
45use std::{collections::HashMap, env, time::Duration};
46
47const ESI_DATASOURCE: &str = "tranquility";
48const AUTHORIZE_URL: &str = "https://login.eveonline.com/v2/oauth/authorize";
49const TOKEN_URL: &str = "https://login.eveonline.com/v2/oauth/token";
50const SSO_META_DATA_URL: &str =
51  "https://login.eveonline.com/.well-known/oauth-authorization-server";
52const LOGIN_URLS: [&str; 2] = ["login.eveonline.com", "https://login.eveonline.com"];
53const LOGIN_MEMBERS: [&str; 1] = ["EVE Online"];
54
55pub type ClientType = oauth2::Client<
56  BasicErrorResponse,
57  BasicTokenResponse,
58  BasicTokenIntrospectionResponse,
59  StandardRevocableToken,
60  BasicRevocationErrorResponse,
61  EndpointSet,
62  EndpointNotSet,
63  EndpointNotSet,
64  EndpointNotSet,
65  EndpointSet,
66>;
67
68pub struct Esi {
69  client: Client,
70  token_client: ClientType,
71  base_url: String,
72}
73
74impl Esi {
75  pub fn new(
76    owner_id: impl Into<String>,
77    client_id: impl Into<String>,
78    client_secret: impl Into<String>,
79    callback_url: impl Into<String>,
80    timeout: u64,
81  ) -> EsiResult<Self> {
82    let version = env!("CARGO_PKG_VERSION");
83    let user_agent = format!(
84      "{}/{}; {} ({} {})",
85      "eversal",
86      version,
87      owner_id.into(),
88      "ben@bensherriff.com",
89      "https://gitea.bensherriff.com/Eversal/eversal-esi"
90    );
91    let client = Client::builder()
92      .user_agent(&user_agent)
93      .timeout(Duration::from_secs(timeout))
94      .build()?;
95
96    let token_client = BasicClient::new(ClientId::new(client_id.into()))
97      .set_client_secret(ClientSecret::new(client_secret.into()))
98      .set_auth_uri(AuthUrl::new(AUTHORIZE_URL.to_string())?)
99      .set_token_uri(TokenUrl::new(TOKEN_URL.to_string())?)
100      .set_redirect_uri(RedirectUrl::new(callback_url.into())?);
101
102    Ok(Self {
103      client,
104      token_client,
105      base_url: "https://esi.evetech.net/latest/".to_string(),
106    })
107  }
108}
109
110#[derive(Debug, serde::Serialize, serde::Deserialize)]
111pub struct Headers {
112  pub etag: String,
113  pub expires: String,
114  pub last_modified: String,
115  pub error_limit_remain: u16,
116  pub error_limit_reset: u16,
117}
118
119#[derive(Debug, serde::Serialize, serde::Deserialize)]
120pub struct Paged<T> {
121  pub data: T,
122  pub page: i32,
123  pub total_pages: i32,
124}
125
126#[derive(Debug, serde::Serialize, serde::Deserialize)]
127pub struct Response<T> {
128  pub data: T,
129  pub headers: Headers,
130}
131
132async fn process_single<T: DeserializeOwned>(
133  response: reqwest::Response,
134) -> EsiResult<Response<T>> {
135  let headers = headers(&response);
136  let data: T = response.json().await?;
137  Ok(Response { data, headers })
138}
139
140async fn process_paged<T: DeserializeOwned>(
141  page: i32,
142  response: reqwest::Response,
143) -> EsiResult<Response<Paged<T>>> {
144  let total_pages = total_pages(&response);
145  let headers = headers(&response);
146  let data: T = response.json().await?;
147  Ok(Response {
148    data: Paged {
149      data,
150      page,
151      total_pages,
152    },
153    headers,
154  })
155}
156
157async fn get_public_base(
158  path: &str,
159  esi: &Esi,
160  params: Option<HashMap<&str, String>>,
161  etag: Option<&str>,
162) -> EsiResult<reqwest::Response> {
163  let url = build_url(&esi.base_url, path, params);
164  log::trace!("Requesting: {}", url);
165  let mut request = esi.client.get(url);
166  if let Some(etag) = etag {
167    request = request.header(reqwest::header::IF_NONE_MATCH, etag);
168  }
169  let response: reqwest::Response = request.send().await?;
170
171  response.error_for_status_ref()?;
172  if response.status().as_u16() == 304 {
173    return Err(Error::new(304, "Not Modified".to_string()));
174  }
175  Ok(response)
176}
177
178pub async fn get_public<T: DeserializeOwned>(
179  path: &str,
180  esi: &Esi,
181  params: Option<HashMap<&str, String>>,
182  etag: Option<&str>,
183) -> EsiResult<Response<T>> {
184  let response = get_public_base(path, esi, params, etag).await?;
185  process_single(response).await
186}
187
188pub async fn get_public_paged<T: DeserializeOwned>(
189  path: &str,
190  esi: &Esi,
191  params: Option<HashMap<&str, String>>,
192  etag: Option<&str>,
193) -> EsiResult<Response<Paged<T>>> {
194  let page = page(&params);
195  let response = get_public_base(path, esi, params, etag).await?;
196  process_paged(page, response).await
197}
198
199async fn get_authenticated_base(
200  path: &str,
201  esi: &Esi,
202  access_token: &str,
203  params: Option<HashMap<&str, String>>,
204  etag: Option<&str>,
205) -> EsiResult<reqwest::Response> {
206  let url = build_url(&esi.base_url, path, params);
207  log::trace!("Requesting: {}", url);
208
209  let mut request = esi.client.get(url).header(
210    reqwest::header::AUTHORIZATION,
211    format!("Bearer {}", access_token),
212  );
213  if let Some(etag) = etag {
214    request = request.header(reqwest::header::IF_NONE_MATCH, etag);
215  }
216  let response = request.send().await?;
217
218  response.error_for_status_ref()?;
219  if response.status().as_u16() == 304 {
220    return Err(Error::new(304, "Not Modified".to_string()));
221  }
222
223  Ok(response)
224}
225
226pub async fn get_authenticated<T: DeserializeOwned>(
227  access_token: &str,
228  path: &str,
229  esi: &Esi,
230  params: Option<HashMap<&str, String>>,
231  etag: Option<&str>,
232) -> EsiResult<Response<T>> {
233  let response = get_authenticated_base(path, esi, access_token, params, etag).await?;
234  process_single(response).await
235}
236
237pub async fn get_authenticated_paged<T: DeserializeOwned>(
238  access_token: &str,
239  path: &str,
240  esi: &Esi,
241  params: Option<HashMap<&str, String>>,
242  etag: Option<&str>,
243) -> EsiResult<Response<Paged<T>>> {
244  let page = page(&params);
245  let response = get_authenticated_base(&path, esi, access_token, params, etag).await?;
246  process_paged(page, response).await
247}
248
249pub async fn post_public<T: DeserializeOwned, U: Serialize + ?Sized>(
250  path: &str,
251  esi: &Esi,
252  params: Option<HashMap<&str, String>>,
253  data: &U,
254) -> Result<T, reqwest::Error> {
255  let url = build_url(&esi.base_url, path, params);
256  log::trace!(
257    "Requesting: {} with data: {}",
258    url,
259    serde_json::to_string(data).unwrap()
260  );
261  let response = esi.client.post(url).json(data).send().await?;
262
263  response.error_for_status_ref()?;
264  let result: T = response.json().await?;
265  Ok(result)
266}
267
268pub async fn post_authenticated<T: DeserializeOwned, U: Serialize + ?Sized>(
269  access_token: &str,
270  path: &str,
271  esi: &Esi,
272  params: Option<HashMap<&str, String>>,
273  data: &U,
274) -> Result<T, reqwest::Error> {
275  let url = build_url(&esi.base_url, path, params);
276  log::trace!(
277    "Requesting: {} with data: {}",
278    url,
279    serde_json::to_string(data).unwrap()
280  );
281  let req = esi
282    .client
283    .post(url)
284    .header(
285      reqwest::header::AUTHORIZATION,
286      format!("Bearer {}", access_token),
287    )
288    .json(data)
289    .send()
290    .await?;
291
292  req.error_for_status_ref()?;
293  let result: T = req.json().await?;
294  Ok(result)
295}
296
297fn build_url(base_url: &str, path: &str, params: Option<HashMap<&str, String>>) -> String {
298  let mut url = format!("{}{}?datasource={}", base_url, path, ESI_DATASOURCE);
299  if let Some(params) = params {
300    for (key, value) in params {
301      url.push_str(&format!("&{}={}", key, value));
302    }
303  }
304  url
305}
306
307fn headers(response: &reqwest::Response) -> Headers {
308  Headers {
309    etag: response
310      .headers()
311      .get("ETag")
312      .unwrap()
313      .to_str()
314      .unwrap()
315      .to_string(),
316    // Parse from RFC 1123
317    expires: response
318      .headers()
319      .get("Expires")
320      .unwrap()
321      .to_str()
322      .unwrap()
323      .to_string(),
324    last_modified: response
325      .headers()
326      .get("Last-Modified")
327      .unwrap()
328      .to_str()
329      .unwrap()
330      .to_string(),
331    error_limit_remain: response
332      .headers()
333      .get("x-esi-error-limit-remain")
334      .unwrap()
335      .to_str()
336      .unwrap()
337      .to_string()
338      .parse()
339      .unwrap(),
340    error_limit_reset: response
341      .headers()
342      .get("x-esi-error-limit-reset")
343      .unwrap()
344      .to_str()
345      .unwrap()
346      .to_string()
347      .parse()
348      .unwrap(),
349  }
350}
351
352fn page(params: &Option<HashMap<&str, String>>) -> i32 {
353  match &params {
354    Some(params) => match params.get("page") {
355      Some(page) => page.parse::<i32>().unwrap_or_else(|_| 1),
356      None => 1,
357    },
358    None => 1,
359  }
360}
361
362fn total_pages(response: &reqwest::Response) -> i32 {
363  match response.headers().get("X-Pages") {
364    Some(pages) => match pages.to_str() {
365      Ok(pages) => pages.parse::<i32>().unwrap_or_else(|_| 1),
366      Err(_) => 1,
367    },
368    None => 1,
369  }
370}