worldtimeapi/
service.rs

1use std::{collections::HashMap, fmt::Write, panic, string::ToString};
2
3use derive_more::derive::Display;
4use serde_json::Value;
5
6use crate::schema::DateTimeJson;
7
8/// Create a World Time API client for specified endpoint.
9///
10/// Support endpoints are "timezone" and "ip".
11#[derive(Debug)]
12pub struct Client {
13    regions: Vec<Value>,
14    url: String,
15}
16
17/// An enumeration of the supported endpoints for the World Time API client.
18///
19/// The World Time API provides two endpoints: "timezone" and "ip".
20/// The "timezone" endpoint allows querying the current time for a specific timezone region.
21/// The "ip" endpoint allows querying the current time for a specific IP address.
22#[derive(Debug, Display, Clone, Copy)]
23pub enum Endpoint {
24    /// The "timezone" endpoint.
25    #[display("timezone")]
26    Timezone,
27    /// The "ip" endpoint.
28    #[display("ip")]
29    Ip,
30}
31
32impl Client {
33    /// Create a new client for the specified endpoint.
34    ///
35    /// # Errors
36    ///
37    /// This function will return an error if:
38    /// - The request to the World Time API fails (e.g., network issues).
39    /// - The response cannot be deserialized into a JSON value.
40    pub async fn new(endpoint: Endpoint) -> Result<Self, reqwest::Error> {
41        // for the timezone endpoint, define a region list property
42        let regions: Vec<Value> = match endpoint {
43            Endpoint::Timezone => {
44                let url = "https://worldtimeapi.org/api/timezone/".to_string();
45                let response = reqwest::get(&url).await?;
46
47                response.json().await?
48            }
49            Endpoint::Ip => {
50                let url = "https://worldtimeapi.org/api/ip".to_string();
51                let response = reqwest::get(&url).await?;
52
53                response.json().await?
54            }
55        };
56
57        let url: String = format!("https://worldtimeapi.org/api/{endpoint}");
58
59        Ok(Self { regions, url })
60    }
61
62    /// Get the current time for the specified region.
63    ///
64    /// # Errors
65    ///
66    /// This function will return an error if:
67    /// - The request to the World Time API fails.
68    /// - The response cannot be deserialized into a `DateTimeJson`.
69    /// - The `payload` is missing required keys like `area`, or contains invalid keys, in which case it panics.
70    ///
71    /// # Panics
72    ///
73    /// This function will panic if:
74    /// - The `payload` contains invalid keys not among "area", "location", or "region".
75    /// - The required key `area` is missing.
76    /// - The `region` key is provided without a `location`.
77    pub async fn get(&self, payload: HashMap<&str, &str>) -> Result<DateTimeJson, reqwest::Error> {
78        let keys = payload
79            .keys()
80            .map(ToString::to_string)
81            .collect::<Vec<String>>();
82        let mut args = String::new();
83
84        for item in keys.clone() {
85            if !["area", "location", "region"]
86                .iter()
87                .map(ToString::to_string)
88                .any(|x| x == *item)
89            {
90                panic!("Invalid key: {item}");
91            }
92        }
93
94        if keys.contains(&"area".to_string()) {
95            write!(args, "/{}", payload["area"]).unwrap();
96        } else {
97            panic!("Missing key: area");
98        }
99
100        if keys.contains(&"location".to_string()) {
101            write!(args, "/{}", payload["location"]).unwrap();
102        }
103
104        if keys.contains(&"location".to_string()) && keys.contains(&"region".to_string()) {
105            write!(args, "/{}", payload["region"]).unwrap();
106        } else if !keys.contains(&"location".to_string()) && keys.contains(&"region".to_string()) {
107            panic!("Missing key: region");
108        }
109
110        let response = reqwest::get(&format!("{}{}", self.url, args))
111            .await?
112            .json::<DateTimeJson>()
113            .await?;
114        Ok(response)
115    }
116
117    /// Get a reference to the client's regions.
118    #[must_use]
119    pub fn regions(&self) -> &[Value] {
120        self.regions.as_ref()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    use std::collections::HashMap;
129
130    #[tokio::test]
131    async fn test_client_get_success() {
132        let mut server = mockito::Server::new_async().await;
133        let mock_response = r#"
134        {
135            "abbreviation": "EDT",
136            "client_ip": "192.0.2.1",
137            "datetime": "2024-09-23T14:23:48+00:00",
138            "day_of_week": 1,
139            "day_of_year": 266,
140            "dst": true,
141            "dst_from": null,
142            "dst_offset": 3600,
143            "dst_until": null,
144            "raw_offset": -18000,
145            "timezone": "America/New_York",
146            "unixtime": 1695475428,
147            "utc_datetime": "2024-09-23T14:23:48Z",
148            "utc_offset": "-05:00",
149            "week_number": 39
150        }
151        "#;
152
153        // Mocking the World Time API for a timezone query
154        let _m = server
155            .mock("GET", "/api/timezone/America/New_York")
156            .with_status(200)
157            .with_body(mock_response)
158            .create();
159
160        let client = Client {
161            regions: vec![],
162            url: format!("{}/api/timezone", server.url()),
163        };
164
165        let mut payload = HashMap::new();
166        payload.insert("area", "America");
167        payload.insert("location", "New_York");
168
169        let response = client.get(payload).await.unwrap();
170
171        // Verifying that the response matches the mock response
172        let expected = serde_json::from_str::<DateTimeJson>(mock_response).unwrap();
173        assert_eq!(response.abbreviation(), expected.abbreviation());
174        assert_eq!(response.client_ip(), expected.client_ip());
175    }
176
177    #[tokio::test]
178    #[should_panic(expected = "Invalid key: invalid_key")]
179    async fn test_client_get_invalid_key() {
180        let server = mockito::Server::new_async().await;
181        let client = Client {
182            regions: vec![],
183            url: format!("{}/api/timezone", server.url()),
184        };
185
186        let mut payload = HashMap::new();
187        payload.insert("invalid_key", "America");
188
189        // This should panic because "invalid_key" is not a valid key
190        client.get(payload).await.unwrap();
191    }
192
193    #[tokio::test]
194    #[should_panic(expected = "Missing key: area")]
195    async fn test_client_get_missing_area() {
196        let server = mockito::Server::new_async().await;
197        let client = Client {
198            regions: vec![],
199            url: format!("{}/api/timezone", server.url()),
200        };
201
202        let mut payload = HashMap::new();
203        payload.insert("location", "New_York");
204
205        // This should panic because the "area" key is missing
206        client.get(payload).await.unwrap();
207    }
208
209    #[test]
210    fn test_endpoint_display() {
211        assert_eq!(Endpoint::Timezone.to_string(), "timezone");
212        assert_eq!(Endpoint::Ip.to_string(), "ip");
213    }
214}