foxhole_api/
lib.rs

1//! Foxhole War API.
2//!
3//! This crate is a wrapper around the [Foxhole War API](https://github.com/clapfoot/warapi). This
4//! crate requires the use of async code, as well as the [Tokio](https://docs.rs/tokio) runtime.
5//!
6//! Usage example:
7//!
8//! ```
9//! use foxhole_api::Client;
10//!
11//! #[tokio::main]
12//! async fn main() {
13//!     // The default shard is Live-1
14//!     let client = Client::default();
15//!
16//!     let war_data = client.war_data().await.unwrap();
17//!     let map_names = client.map_names().await.unwrap();
18//!     let static_map_data = client.map_data_static("TheFingersHex").await.unwrap();
19//!     let dynamic_map_data = client.map_data_dynamic("TheFingersHex").await.unwrap();
20//! }
21//! ```
22
23pub mod response_types;
24
25use thiserror::Error;
26
27use reqwest::Error as ReqwestError;
28use response_types::{MapDataResponse, MapNameResponse, WarDataResponse, WarReportResponse};
29use serde::de::DeserializeOwned;
30
31const MAP_NAME: &str = "/worldconquest/maps";
32const WAR_DATA: &str = "/worldconquest/war";
33
34#[derive(Error, Debug)]
35pub enum FoxholeApiError {
36    #[error("Error fetching data from the war API")]
37    FetchError(#[from] ReqwestError),
38}
39
40/// Used to specify the shard of the api
41pub enum Shard {
42    Live1,
43    Live2,
44}
45
46/// Client for fetching data from the war API.
47///
48/// This client contains an HTTP client, and only one instance should be needed per process.
49pub struct Client {
50    web_client: reqwest::Client,
51    #[allow(dead_code)]
52    shard: Shard,
53}
54
55impl Client {
56    pub fn new(shard: Shard) -> Self {
57        let web_client = reqwest::Client::new();
58
59        Self { web_client, shard }
60    }
61
62    /// Retrieves information about the current war.
63    ///
64    /// This endpoint retrieves information about the current war, and returns it deserialized as
65    /// [`WarDataResponse`].
66    pub async fn war_data(&self) -> Result<WarDataResponse, FoxholeApiError> {
67        let war_data: WarDataResponse = self.get_response(WAR_DATA.to_string()).await?;
68
69        Ok(war_data)
70    }
71
72    /// Retrieves all map names.
73    ///
74    /// This endpoint retrieves all map names currently present, and returns them deserialized as
75    /// [`MapNameResponse`].
76    pub async fn map_names(&self) -> Result<MapNameResponse, FoxholeApiError> {
77        let maps: Vec<String> = self.get_response(MAP_NAME.to_string()).await?;
78        let map_data = MapNameResponse { maps };
79
80        Ok(map_data)
81    }
82
83    /// Retrieves the war report for a given map.
84    ///
85    /// This endpoint retrieves the war report for a given map, and returns it deserialized as
86    /// [`WarReportResponse`].
87    pub async fn map_war_report(
88        &self,
89        map_name: &str,
90    ) -> Result<WarReportResponse, FoxholeApiError> {
91        let endpoint_string = format!("/worldconquest/warReport/{}", map_name);
92        let war_report: WarReportResponse = self.get_response(endpoint_string).await?;
93
94        Ok(war_report)
95    }
96
97    /// Retrieves all static map data.
98    ///
99    /// This endpoint retrieves all map data that will never change over the course of a war. This
100    /// includes map text labels and resource node locations.
101    pub async fn map_data_static(
102        &self,
103        map_name: &str,
104    ) -> Result<MapDataResponse, FoxholeApiError> {
105        // FIXME: Write a macro for this to avoid copy pasta
106        let endpoint_string = format!("/worldconquest/maps/{}/static", map_name);
107        let map_data: MapDataResponse = self.get_response(endpoint_string).await?;
108
109        Ok(map_data)
110    }
111
112    /// Retrieves all dynamic map data.
113    ///
114    /// This endpoint retrieves all map daa that could change over the course of a war. This
115    /// includes relic bases, and town halls that could change team ownership. Private data, such as
116    /// player built fortifications, is not available.
117    pub async fn map_data_dynamic(
118        &self,
119        map_name: &str,
120    ) -> Result<MapDataResponse, FoxholeApiError> {
121        // FIXME: Write a macro for this to avoid copy pasta
122        let endpoint_string = format!("/worldconquest/maps/{}/dynamic/public", map_name);
123        let map_data: MapDataResponse = self.get_response(endpoint_string).await?;
124
125        Ok(map_data)
126    }
127
128    async fn get_response<T>(&self, endpoint: String) -> Result<T, FoxholeApiError>
129    where
130        T: DeserializeOwned,
131    {
132        let request_string = self.build_request(endpoint);
133        let response = self.web_client.get(request_string.clone()).send().await?;
134        let response = response.json::<T>().await?;
135
136        Ok(response)
137    }
138
139    fn build_request(&self, endpoint: String) -> String {
140        #[cfg(not(test))]
141        let mut request_string = self.get_shard_url();
142
143        #[cfg(test)]
144        let mut request_string = mockito::server_url();
145
146        request_string.push_str(endpoint.as_str());
147        request_string
148    }
149
150    #[cfg(not(test))]
151    fn get_shard_url(&self) -> String {
152        let shard = match self.shard {
153            Shard::Live1 => "live",
154            Shard::Live2 => "live-2",
155        };
156
157        format!("https://war-service-{}.foxholeservices.com/api", shard)
158    }
159}
160
161impl Default for Client {
162    fn default() -> Self {
163        Client::new(Shard::Live1)
164    }
165}
166
167#[cfg(test)]
168mod test {
169    use crate::response_types::{IconType, MapItem, MapMarkerType, MapTextItem, TeamId};
170
171    use super::*;
172    use mockito::{mock, Mock};
173
174    fn build_mock(endpoint: &str, body: &'static str) -> Mock {
175        mock("GET", endpoint)
176            .with_status(200)
177            .with_header("content-type", "application/json")
178            .with_body(body)
179            .create()
180    }
181
182    #[tokio::test]
183    async fn test_war_data() {
184        let war_data_string = r#"{
185            "warId" : "1e82269a-d82b-4350-b1b1-06a98c983503",
186            "warNumber" : 83,
187            "winner" : "NONE",
188            "conquestStartTime" : 1632326703205,
189            "conquestEndTime" : null,
190            "resistanceStartTime" : null,
191            "requiredVictoryTowns" : 32
192        }"#;
193
194        let expected_response = WarDataResponse {
195            war_id: "1e82269a-d82b-4350-b1b1-06a98c983503".to_string(),
196            war_number: 83,
197            winner: TeamId::None,
198            conquest_start_time: 1632326703205,
199            conquest_end_time: None,
200            resistance_start_time: None,
201            required_victory_towns: 32,
202        };
203
204        let _m = build_mock(WAR_DATA, war_data_string);
205
206        let client = Client::default();
207        let response = client.war_data().await.unwrap();
208        assert_eq!(expected_response, response);
209    }
210
211    #[tokio::test]
212    async fn test_map_name() {
213        let map_name_string = r#"[
214            "TheFingersHex",
215            "GreatMarchHex",
216            "TempestIslandHex"
217          ]"#;
218
219        let _m = build_mock(MAP_NAME, map_name_string);
220
221        let maps = vec![
222            "TheFingersHex".to_string(),
223            "GreatMarchHex".to_string(),
224            "TempestIslandHex".to_string(),
225        ];
226        let expected_response = MapNameResponse { maps };
227
228        let client = Client::default();
229        let response = client.map_names().await.unwrap();
230        assert_eq!(expected_response, response);
231    }
232
233    #[tokio::test]
234    async fn test_map_data_static() {
235        let map_data_string = r#"{
236            "regionId": 38,
237            "scorchedVictoryTowns": 0,
238            "mapItems": [],
239            "mapTextItems": [
240              {
241                "text": "Captain's Dread",
242                "x": 0.8643478,
243                "y": 0.4387644,
244                "mapMarkerType": "Minor"
245              },
246              {
247                "text": "Cavitatis",
248                "x": 0.43523252,
249                "y": 0.6119927,
250                "mapMarkerType": "Major"
251              }
252            ],
253            "lastUpdated": 1635388391413,
254            "version": 3
255          }"#;
256
257        let map_text_items = vec![
258            MapTextItem {
259                text: "Captain's Dread".to_string(),
260                x: 0.8643478,
261                y: 0.4387644,
262                map_marker_type: MapMarkerType::Minor,
263            },
264            MapTextItem {
265                text: "Cavitatis".to_string(),
266                x: 0.43523252,
267                y: 0.6119927,
268                map_marker_type: MapMarkerType::Major,
269            },
270        ];
271
272        let expected_response = MapDataResponse {
273            region_id: 38,
274            scorched_victory_towns: 0,
275            map_items: Vec::new(),
276            map_text_items,
277            last_updated: 1635388391413,
278            version: 3,
279        };
280
281        // FIXME: Write a macro for this to avoid copy pasta
282        let map_string = "TheFingersHex".to_string();
283        let endpoint_string = format!("/worldconquest/maps/{}/static", map_string);
284        let _m = build_mock(endpoint_string.as_str(), map_data_string);
285
286        let client = Client::default();
287        let response = client.map_data_static(&map_string).await.unwrap();
288        assert_eq!(expected_response, response);
289    }
290
291    #[tokio::test]
292    async fn test_map_data_dynamic() {
293        let map_data_string = r#"{
294            "regionId" : 38,
295            "scorchedVictoryTowns" : 0,
296            "mapItems" : [ {
297              "teamId" : "NONE",
298              "iconType" : 20,
299              "x" : 0.43503433,
300              "y" : 0.83201146,
301              "flags" : 0
302            }, {
303              "teamId" : "COLONIALS",
304              "iconType" : 20,
305              "x" : 0.83840775,
306              "y" : 0.45411408,
307              "flags" : 0
308            }, {
309              "teamId" : "WARDENS",
310              "iconType" : 20,
311              "x" : 0.83840775,
312              "y" : 0.45411408,
313                "flags" : 0
314            } ],
315            "mapTextItems" : [ ],
316            "lastUpdated" : 1635534670643,
317            "version" : 5
318          }"#;
319
320        let map_items = vec![
321            MapItem {
322                team_id: TeamId::None,
323                icon_type: IconType::SalvageField,
324                x: 0.43503433,
325                y: 0.83201146,
326                flags: 0,
327            },
328            MapItem {
329                team_id: TeamId::Colonials,
330                icon_type: IconType::SalvageField,
331                x: 0.83840775,
332                y: 0.45411408,
333                flags: 0,
334            },
335            MapItem {
336                team_id: TeamId::Wardens,
337                icon_type: IconType::SalvageField,
338                x: 0.83840775,
339                y: 0.45411408,
340                flags: 0,
341            },
342        ];
343
344        let expected_response = MapDataResponse {
345            region_id: 38,
346            scorched_victory_towns: 0,
347            map_items,
348            map_text_items: Vec::new(),
349            last_updated: 1635534670643,
350            version: 5,
351        };
352
353        // FIXME: Write a macro for this to avoid copy pasta
354        let map_string = "TheFingersHex".to_string();
355        let endpoint_string = format!("/worldconquest/maps/{}/dynamic/public", map_string);
356        let _m = build_mock(endpoint_string.as_str(), map_data_string);
357
358        let client = Client::default();
359        let response = client.map_data_dynamic(&map_string).await.unwrap();
360        assert_eq!(expected_response, response);
361    }
362
363    #[tokio::test]
364    async fn test_map_war_report() {
365        let war_report_string = r#"{
366            "totalEnlistments" : 1000,
367            "colonialCasualties" : 2000,
368            "wardenCasualties" : 2000,
369            "dayOfWar" : 355
370          }"#;
371
372        let expected_response = WarReportResponse {
373            total_enlistments: 1000,
374            colonial_casualties: 2000,
375            warden_casualties: 2000,
376            day_of_war: 355,
377        };
378
379        let _m = build_mock("/worldconquest/warReport/TheFingersHex", war_report_string);
380
381        let client = Client::default();
382        let response = client.map_war_report("TheFingersHex").await.unwrap();
383        assert_eq!(expected_response, response);
384    }
385}