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(¶ms);
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(¶ms);
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 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 ¶ms {
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}