torn_api/
lib.rs

1#![warn(clippy::all, clippy::perf, clippy::style, clippy::suspicious)]
2
3pub mod into_owned;
4pub mod local;
5pub mod send;
6
7#[cfg(feature = "user")]
8pub mod user;
9
10#[cfg(feature = "faction")]
11pub mod faction;
12
13#[cfg(feature = "market")]
14pub mod market;
15
16#[cfg(feature = "torn")]
17pub mod torn;
18
19#[cfg(feature = "key")]
20pub mod key;
21
22#[cfg(feature = "awc")]
23pub mod awc;
24
25#[cfg(feature = "reqwest")]
26pub mod reqwest;
27
28#[cfg(feature = "__common")]
29pub mod common;
30
31mod de_util;
32
33use std::fmt::Write;
34
35use chrono::{DateTime, Utc};
36use serde::{de::Error as DeError, Deserialize};
37use thiserror::Error;
38
39pub use into_owned::IntoOwned;
40
41pub struct ApiResponse {
42    pub value: serde_json::Value,
43}
44
45#[derive(Error, Debug)]
46pub enum ResponseError {
47    #[error("API: {reason}")]
48    Api { code: u8, reason: String },
49
50    #[error(transparent)]
51    MalformedResponse(#[from] serde_json::Error),
52}
53
54impl ResponseError {
55    pub fn api_code(&self) -> Option<u8> {
56        match self {
57            Self::Api { code, .. } => Some(*code),
58            _ => None,
59        }
60    }
61}
62
63impl ApiResponse {
64    pub fn from_value(mut value: serde_json::Value) -> Result<Self, ResponseError> {
65        #[derive(serde::Deserialize)]
66        struct ApiErrorDto {
67            code: u8,
68            #[serde(rename = "error")]
69            reason: String,
70        }
71        match value.get_mut("error") {
72            Some(error) => {
73                let dto: ApiErrorDto = serde_json::from_value(error.take())?;
74                Err(ResponseError::Api {
75                    code: dto.code,
76                    reason: dto.reason,
77                })
78            }
79            None => Ok(Self { value }),
80        }
81    }
82
83    #[allow(dead_code)]
84    fn decode<'de, D>(&'de self) -> serde_json::Result<D>
85    where
86        D: Deserialize<'de>,
87    {
88        D::deserialize(&self.value)
89    }
90
91    #[allow(dead_code)]
92    fn decode_field<'de, D>(&'de self, field: &'static str) -> serde_json::Result<D>
93    where
94        D: Deserialize<'de>,
95    {
96        self.value
97            .get(field)
98            .ok_or_else(|| serde_json::Error::missing_field(field))
99            .and_then(D::deserialize)
100    }
101
102    #[allow(dead_code)]
103    fn decode_field_with<'de, V, F>(&'de self, field: &'static str, fun: F) -> serde_json::Result<V>
104    where
105        F: FnOnce(&'de serde_json::Value) -> serde_json::Result<V>,
106    {
107        self.value
108            .get(field)
109            .ok_or_else(|| serde_json::Error::missing_field(field))
110            .and_then(fun)
111    }
112}
113
114pub trait ApiSelectionResponse: Send + Sync + From<ApiResponse> + 'static {
115    fn into_inner(self) -> ApiResponse;
116}
117
118pub trait ApiSelection: Send + Sync + 'static {
119    type Response: ApiSelectionResponse;
120
121    fn raw_value(self) -> &'static str;
122
123    fn category() -> &'static str;
124}
125
126pub struct DirectExecutor<C> {
127    key: String,
128    _marker: std::marker::PhantomData<C>,
129}
130
131impl<C> DirectExecutor<C> {
132    fn new(key: String) -> Self {
133        Self {
134            key,
135            _marker: Default::default(),
136        }
137    }
138}
139
140#[derive(Error, Debug)]
141pub enum ApiClientError<C>
142where
143    C: std::error::Error,
144{
145    #[error(transparent)]
146    Client(C),
147
148    #[error(transparent)]
149    Response(#[from] ResponseError),
150}
151
152impl<C> ApiClientError<C>
153where
154    C: std::error::Error,
155{
156    pub fn api_code(&self) -> Option<u8> {
157        match self {
158            Self::Response(err) => err.api_code(),
159            _ => None,
160        }
161    }
162}
163
164#[derive(Debug)]
165pub struct ApiRequest<A>
166where
167    A: ApiSelection,
168{
169    pub selections: Vec<&'static str>,
170    pub query_items: Vec<(&'static str, String)>,
171    pub comment: Option<String>,
172    phantom: std::marker::PhantomData<A>,
173}
174
175impl<A> std::default::Default for ApiRequest<A>
176where
177    A: ApiSelection,
178{
179    fn default() -> Self {
180        Self {
181            selections: Vec::default(),
182            query_items: Vec::default(),
183            comment: None,
184            phantom: Default::default(),
185        }
186    }
187}
188
189impl<A> ApiRequest<A>
190where
191    A: ApiSelection,
192{
193    fn add_query_item(&mut self, name: &'static str, value: impl ToString) {
194        if let Some((_, old)) = self.query_items.iter_mut().find(|(n, _)| *n == name) {
195            *old = value.to_string();
196        } else {
197            self.query_items.push((name, value.to_string()));
198        }
199    }
200
201    pub fn url(&self, key: &str, id: Option<&str>) -> String {
202        let mut url = format!("https://api.torn.com/{}/", A::category());
203
204        if let Some(id) = id {
205            write!(url, "{}", id).unwrap();
206        }
207
208        write!(url, "?selections={}&key={}", self.selections.join(","), key).unwrap();
209
210        for (name, value) in &self.query_items {
211            write!(url, "&{name}={value}").unwrap();
212        }
213
214        if let Some(comment) = &self.comment {
215            write!(url, "&comment={}", comment).unwrap();
216        }
217
218        url
219    }
220}
221
222pub struct ApiRequestBuilder<A>
223where
224    A: ApiSelection,
225{
226    pub request: ApiRequest<A>,
227    pub id: Option<String>,
228}
229
230impl<A> Default for ApiRequestBuilder<A>
231where
232    A: ApiSelection,
233{
234    fn default() -> Self {
235        Self {
236            request: Default::default(),
237            id: None,
238        }
239    }
240}
241
242impl<A> ApiRequestBuilder<A>
243where
244    A: ApiSelection,
245{
246    #[must_use]
247    pub fn selections(mut self, selections: impl IntoIterator<Item = A>) -> Self {
248        self.request.selections.append(
249            &mut selections
250                .into_iter()
251                .map(ApiSelection::raw_value)
252                .collect(),
253        );
254        self
255    }
256
257    #[must_use]
258    pub fn from(mut self, from: DateTime<Utc>) -> Self {
259        self.request.add_query_item("from", from.timestamp());
260        self
261    }
262
263    #[must_use]
264    pub fn from_timestamp(mut self, from: i64) -> Self {
265        self.request.add_query_item("from", from);
266        self
267    }
268
269    #[must_use]
270    pub fn to(mut self, to: DateTime<Utc>) -> Self {
271        self.request.add_query_item("to", to.timestamp());
272        self
273    }
274
275    #[must_use]
276    pub fn to_timestamp(mut self, to: i64) -> Self {
277        self.request.add_query_item("to", to);
278        self
279    }
280
281    #[must_use]
282    pub fn stats_timestamp(mut self, ts: i64) -> Self {
283        self.request.add_query_item("timestamp", ts);
284        self
285    }
286
287    #[must_use]
288    pub fn stats_datetime(mut self, dt: DateTime<Utc>) -> Self {
289        self.request.add_query_item("timestamp", dt.timestamp());
290        self
291    }
292
293    #[must_use]
294    pub fn comment(mut self, comment: String) -> Self {
295        self.request.comment = Some(comment);
296        self
297    }
298
299    #[must_use]
300    pub fn id<I>(mut self, id: I) -> Self
301    where
302        I: ToString,
303    {
304        self.id = Some(id.to_string());
305        self
306    }
307}
308
309#[cfg(test)]
310#[allow(unused)]
311pub(crate) mod tests {
312    use std::sync::Once;
313
314    #[cfg(all(not(feature = "reqwest"), feature = "awc"))]
315    pub use ::awc::Client;
316    #[cfg(feature = "reqwest")]
317    pub use ::reqwest::Client;
318
319    #[cfg(all(not(feature = "reqwest"), feature = "awc"))]
320    pub use crate::local::ApiClient as ClientTrait;
321    #[cfg(feature = "reqwest")]
322    pub use crate::send::ApiClient as ClientTrait;
323
324    #[cfg(all(not(feature = "reqwest"), feature = "awc"))]
325    pub use actix_rt::test as async_test;
326    #[cfg(feature = "reqwest")]
327    pub use tokio::test as async_test;
328
329    use super::*;
330
331    static INIT: Once = Once::new();
332
333    pub(crate) fn setup() -> String {
334        INIT.call_once(|| {
335            dotenv::dotenv().ok();
336        });
337        std::env::var("APIKEY").expect("api key")
338    }
339
340    #[cfg(feature = "user")]
341    #[test]
342    fn selection_raw_value() {
343        assert_eq!(user::Selection::Basic.raw_value(), "basic");
344    }
345
346    #[cfg(all(feature = "reqwest", feature = "user"))]
347    #[tokio::test]
348    async fn reqwest() {
349        let key = setup();
350
351        Client::default().torn_api(key).user(|b| b).await.unwrap();
352    }
353
354    #[cfg(all(feature = "awc", feature = "user"))]
355    #[actix_rt::test]
356    async fn awc() {
357        let key = setup();
358
359        Client::default().torn_api(key).user(|b| b).await.unwrap();
360    }
361
362    #[test]
363    fn url_builder_from_dt() {
364        let url = ApiRequestBuilder::<user::Selection>::default()
365            .from(DateTime::default())
366            .request
367            .url("", None);
368
369        assert_eq!("https://api.torn.com/user/?selections=&key=&from=0", url);
370    }
371
372    #[test]
373    fn url_builder_from_ts() {
374        let url = ApiRequestBuilder::<user::Selection>::default()
375            .from_timestamp(12345)
376            .request
377            .url("", None);
378
379        assert_eq!(
380            "https://api.torn.com/user/?selections=&key=&from=12345",
381            url
382        );
383    }
384
385    #[test]
386    fn url_builder_to_dt() {
387        let url = ApiRequestBuilder::<user::Selection>::default()
388            .to(DateTime::default())
389            .request
390            .url("", None);
391
392        assert_eq!("https://api.torn.com/user/?selections=&key=&to=0", url);
393    }
394
395    #[test]
396    fn url_builder_to_ts() {
397        let url = ApiRequestBuilder::<user::Selection>::default()
398            .to_timestamp(12345)
399            .request
400            .url("", None);
401
402        assert_eq!("https://api.torn.com/user/?selections=&key=&to=12345", url);
403    }
404
405    #[test]
406    fn url_builder_timestamp_dt() {
407        let url = ApiRequestBuilder::<user::Selection>::default()
408            .stats_datetime(DateTime::default())
409            .request
410            .url("", None);
411
412        assert_eq!(
413            "https://api.torn.com/user/?selections=&key=&timestamp=0",
414            url
415        );
416    }
417
418    #[test]
419    fn url_builder_timestamp_ts() {
420        let url = ApiRequestBuilder::<user::Selection>::default()
421            .stats_timestamp(12345)
422            .request
423            .url("", None);
424
425        assert_eq!(
426            "https://api.torn.com/user/?selections=&key=&timestamp=12345",
427            url
428        );
429    }
430
431    #[test]
432    fn url_builder_duplicate() {
433        let url = ApiRequestBuilder::<user::Selection>::default()
434            .from(DateTime::default())
435            .from_timestamp(12345)
436            .request
437            .url("", None);
438
439        assert_eq!(
440            "https://api.torn.com/user/?selections=&key=&from=12345",
441            url
442        );
443    }
444
445    #[test]
446    fn url_builder_many_options() {
447        let url = ApiRequestBuilder::<user::Selection>::default()
448            .from(DateTime::default())
449            .to_timestamp(60)
450            .stats_timestamp(12345)
451            .selections([user::Selection::PersonalStats])
452            .request
453            .url("KEY", Some("1"));
454
455        assert_eq!(
456            "https://api.torn.com/user/1?selections=personalstats&key=KEY&from=0&to=60&timestamp=12345",
457            url
458        );
459    }
460}