1pub 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
40pub enum Shard {
42 Live1,
43 Live2,
44}
45
46pub 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 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 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 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 pub async fn map_data_static(
102 &self,
103 map_name: &str,
104 ) -> Result<MapDataResponse, FoxholeApiError> {
105 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 pub async fn map_data_dynamic(
118 &self,
119 map_name: &str,
120 ) -> Result<MapDataResponse, FoxholeApiError> {
121 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 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 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}