iseven_api/
lib.rs

1//! A Rust wrapper for [isEven API](https://isevenapi.xyz/).
2//!
3//! Includes the library as well as a simple command line app.
4//!
5//! # Examples
6//! ```
7//! use std::error::Error;
8//! use iseven_api::IsEvenApiClient;
9//!
10//! #[tokio::main]
11//! async fn main() -> Result<(), Box<dyn Error>> {
12//!     // Initialise the client
13//!     let client = IsEvenApiClient::new();
14//!
15//!     // Make requests
16//!     let odd_num = client.get(41).await?;
17//!     let even_num = client.get(42).await?;
18//!     assert!(odd_num.isodd());
19//!     assert!(even_num.iseven());
20//!
21//!     Ok(())
22//! }
23//! ```
24//!
25//! # Crate features
26//! - **blocking** - Enables [`IsEvenApiBlockingClient`] which is a blocking alternative to [`IsEvenApiClient`]
27//! and does not require async runtime. It also enables 'convenience' functions [`is_odd`] and [`is_even`].
28
29#![warn(missing_docs)]
30
31use std::fmt::{Display, Formatter};
32
33use log::debug;
34use reqwest::{Client, Response, StatusCode};
35use serde::Deserialize;
36
37const API_URL: &str = "https://api.isevenapi.xyz/api/iseven/";
38
39/// Checks if a number is even.
40///
41/// # Panics
42///
43/// This method will panic if it encounters an error. Use [`IsEvenApiClient`] or [`IsEvenApiBlockingClient`]
44/// if you want to handle failures more gracefully.
45///
46/// As this function internally uses blocking HTTP client, this client must also not be used in an async runtime.
47///
48///
49/// # Examples
50/// ```
51/// use iseven_api::is_even;
52///
53/// # fn main() {
54/// assert!(is_even(42));
55/// # }
56#[cfg(feature = "blocking")]
57pub fn is_even<T: Display>(number: T) -> bool {
58    IsEvenApiBlockingClient::new().get(number).unwrap().iseven()
59}
60
61/// Checks if a number is odd.
62///
63/// # Panics
64///
65/// This method will panic if it encounters an error. Use [`IsEvenApiClient`] or [`IsEvenApiBlockingClient`]
66/// if you want to handle failures more gracefully.
67///
68/// As this function internally uses blocking HTTP client, this client must also not be used in an async runtime.
69///
70///
71/// # Examples
72/// ```
73/// use iseven_api::is_odd;
74///
75/// # fn main() {
76/// assert!(is_odd(333));
77/// # }
78#[cfg(feature = "blocking")]
79pub fn is_odd<T: Display>(number: T) -> bool {
80    !is_even(number)
81}
82
83/// Asynchronous API client for isEven API.
84///
85/// If you need a blocking client, use [`IsEvenApiBlockingClient`] instead.
86///
87/// If you're making multiple requests, it's probably a good idea to reuse the client to take advantage of keep-alive
88/// connection pooling. ([Learn more](https://docs.rs/reqwest/latest/reqwest/index.html#making-a-get-request))
89///
90/// # Examples
91///
92/// ```
93/// # use std::error::Error;
94/// use iseven_api::IsEvenApiClient;
95///
96/// # #[tokio::main]
97/// # async fn main() -> Result<(), Box<dyn Error>> {
98/// // Initialise the client
99/// let client = IsEvenApiClient::new();
100///
101/// // Make requests
102/// let odd_num = client.get(41).await?;
103/// let even_num = client.get(42).await?;
104/// assert!(odd_num.isodd());
105/// assert!(even_num.iseven());
106/// #
107/// #   Ok(())
108/// # }
109/// ```
110#[derive(Debug, Clone)]
111pub struct IsEvenApiClient {
112    client: Client,
113}
114
115impl IsEvenApiClient {
116    /// Creates a new instance of [`IsEvenApiClient`] with a default HTTP client.
117    pub fn new() -> Self {
118        Self::with_client(Client::new())
119    }
120
121    /// Creates a new instance of [`IsEvenApiClient`] with a supplied [`reqwest::Client`].
122    pub fn with_client(client: Client) -> Self {
123        debug!("Creating async HTTP client");
124        Self { client }
125    }
126
127    /// sends a GET request to the isEven API for a given number. The return value includes the `bool`
128    /// value of whether the number is even (`true` indicates an even number) as well as the
129    /// advertisement.
130    ///
131    /// # Errors
132    /// Returns an [`IsEvenApiError`] if either the API request responded with an error or there is an error in the
133    /// request or parsing of the response.
134    ///
135    /// * If the number is outside the range for your [pricing plan](https://isevenapi.xyz/#pricing),
136    /// it will return [`IsEvenApiError::NumberOutOfRange`].
137    /// * If the input is not a valid number, it returns [`IsEvenApiError::InvalidNumber`].
138    /// * For other API error reponses, it returns [`IsEvenApiError::UnknownErrorResponse`] along with an HTTP status code.
139    /// * If the error is in the request [`IsEvenApiError::NetworkError`] is returned.
140    pub async fn get<T: Display>(&self, number: T) -> Result<IsEvenApiResponse, IsEvenApiError> {
141        let response = self.fetch_response(number).await?;
142        let status = response.status();
143        parse_response(response.json().await?, status)
144    }
145
146    /// sends a GET request to the isEven API for a given number and returns its JSON response as a `String`.
147    ///
148    /// # Errors
149    ///
150    /// Unlike [`Self::get`], error responses will NOT be considered an error. Only request failures will be reported
151    /// as an error.
152    pub async fn get_json<T: Display>(&self, number: T) -> Result<String, IsEvenApiError> {
153        let response = self.fetch_response(number).await?;
154        Ok(response.text().await.expect("Unable to decode response body"))
155    }
156
157    /// Make the actual web request
158    async fn fetch_response<T: Display>(&self, number: T) -> reqwest::Result<Response> {
159        let request_url = format!("{api_url}{num}", api_url = API_URL, num = number);
160        debug!("Fetching API response from {}", request_url);
161        self.client.get(request_url).send().await
162    }
163}
164
165impl Default for IsEvenApiClient {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171/// Blocking API client for isEven API.
172///
173///
174/// If you're making multiple requests, it's probably a good idea to reuse the client to take advantage of keep-alive
175/// connection pooling. ([Learn more](https://docs.rs/reqwest/latest/reqwest/index.html#making-a-get-request))
176///
177/// As per [`reqwest::blocking`] restriction, this client must not be used in an async runtime. Please use
178/// [`IsEvenApiClient`] for that.
179///
180/// # Examples
181///
182/// ```
183/// # use std::error::Error;
184/// use iseven_api::IsEvenApiBlockingClient;
185///
186/// # fn main() -> Result<(), Box<dyn Error>> {
187/// // Initialise the client
188/// let client = IsEvenApiBlockingClient::new();
189///
190/// // Make requests
191/// let odd_num = client.get(41)?;
192/// let even_num = client.get(42)?;
193/// assert!(odd_num.isodd());
194/// assert!(even_num.iseven());
195/// #
196/// #   Ok(())
197/// # }
198/// ```
199#[cfg(feature = "blocking")]
200#[derive(Debug, Clone)]
201pub struct IsEvenApiBlockingClient {
202    client: reqwest::blocking::Client,
203}
204
205#[cfg(feature = "blocking")]
206impl IsEvenApiBlockingClient {
207    /// Creates a new instance of [`IsEvenApiBlockingClient`] with a default HTTP client.
208    pub fn new() -> Self {
209        Self::with_client(reqwest::blocking::Client::new())
210    }
211
212    /// Creates a new instance of [`IsEvenApiBlockingClient`] with a supplied [`reqwest::Client`].
213    pub fn with_client(client: reqwest::blocking::Client) -> Self {
214        debug!("Creating blocking HTTP client");
215        Self { client }
216    }
217
218    /// sends a GET request to the isEven API for a given number. The return value includes the `bool`
219    /// value of whether the number is even (`true` indicates an even number) as well as the
220    /// advertisement.
221    ///
222    /// # Errors
223    /// See [`IsEvenApiClient::get`] for a list of possible errors.
224    pub fn get<T: Display>(&self, number: T) -> Result<IsEvenApiResponse, IsEvenApiError> {
225        let response = self.fetch_response(number)?;
226        let status = response.status();
227        parse_response(response.json()?, status)
228    }
229
230    /// sends a GET request to the isEven API for a given number and returns its JSON response as a `String`.
231    /// # Errors
232    ///
233    /// Unlike [`Self::get`], error responses will NOT be considered an error. Only request failures will be reported
234    /// as an error.
235    pub fn get_json<T: Display>(&self, number: T) -> Result<String, IsEvenApiError> {
236        let response = self.fetch_response(number)?;
237        Ok(response.text().expect("Unable to decode response body"))
238    }
239
240    /// Make the actual web request
241    fn fetch_response<T: Display>(&self, number: T) -> reqwest::Result<reqwest::blocking::Response> {
242        let request_url = format!("{api_url}{num}", api_url = API_URL, num = number);
243        debug!("Fetching API response from {}", request_url);
244        self.client.get(request_url).send()
245    }
246}
247
248#[cfg(feature = "blocking")]
249impl Default for IsEvenApiBlockingClient {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255/// Struct containing the return response from the API.
256#[derive(Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
257pub struct IsEvenApiResponse {
258    ad: String,
259    iseven: bool,
260}
261
262impl IsEvenApiResponse {
263    /// Returns `true` if the number is even.
264    pub fn iseven(&self) -> bool {
265        self.iseven
266    }
267
268    /// Returns the ad message.
269    pub fn ad(&self) -> &str {
270        &self.ad
271    }
272
273    /// Returns `true` if the number is odd.
274    pub fn isodd(&self) -> bool {
275        !self.iseven()
276    }
277}
278
279impl Display for IsEvenApiResponse {
280    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
281        write!(f, "{}", if self.iseven { "even" } else { "odd" })
282    }
283}
284
285/// Struct containing the error response from the API.
286#[derive(thiserror::Error, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
287#[error("{}", self.error)]
288pub struct IsEvenApiErrorResponse {
289    error: String,
290}
291
292impl IsEvenApiErrorResponse {
293    /// Returns the error message.
294    pub fn error(&self) -> &str {
295        &self.error
296    }
297}
298
299/// An error type containing errors which can result from the API call.
300#[derive(thiserror::Error, Debug)]
301pub enum IsEvenApiError {
302    /// Number out of range for your [pricing plan](https://isevenapi.xyz/#pricing)
303    #[error(transparent)]
304    NumberOutOfRange(IsEvenApiErrorResponse),
305    /// Invalid number specified
306    #[error(transparent)]
307    InvalidNumber(IsEvenApiErrorResponse),
308    /// Unknown error response received, with HTTP status code
309    #[error("Server returned status code {1}: {0}")]
310    UnknownErrorResponse(IsEvenApiErrorResponse, StatusCode),
311    /// Error in making API request
312    #[error("network error: {0}")]
313    NetworkError(#[from] reqwest::Error),
314}
315
316/// Enum of response types for serde
317#[derive(Deserialize, Debug)]
318#[serde(untagged)]
319enum IsEvenResponseType {
320    Ok(IsEvenApiResponse),
321    Err(IsEvenApiErrorResponse),
322}
323
324fn parse_response(
325    json: IsEvenResponseType,
326    status: StatusCode,
327) -> Result<IsEvenApiResponse, IsEvenApiError> {
328    match json {
329        IsEvenResponseType::Ok(r) => Ok(r),
330        IsEvenResponseType::Err(e) => match status.as_u16() {
331            400 => Err(IsEvenApiError::InvalidNumber(e)),
332            401 => Err(IsEvenApiError::NumberOutOfRange(e)),
333            _ => Err(IsEvenApiError::UnknownErrorResponse(e, status)),
334        },
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use crate::*;
341
342    const ODD_INTS: [i32; 5] = [1, 3, 5, 9, 5283];
343    const EVEN_INTS: [i32; 5] = [0, 2, 8, 10, 88888];
344    const OUT_OF_RANGE_INTS: [i32; 3] = [1000000, i32::MAX, -1];
345    const INVALID_INPUT: [&str; 4] = ["abc", "1.0.0", "hello world.as_u16()", "3.14"];
346
347    #[tokio::test]
348    async fn test_valid_int() {
349        let client = IsEvenApiClient::new();
350        for (&a, b) in ODD_INTS.iter().zip(EVEN_INTS) {
351            assert!(client.get(a).await.unwrap().isodd());
352            assert!(client.get(b).await.unwrap().iseven());
353        }
354    }
355
356    #[tokio::test]
357    async fn test_out_of_range() {
358        let client = IsEvenApiClient::new();
359        for &a in OUT_OF_RANGE_INTS.iter() {
360            assert!(client.get(a).await.is_err());
361        }
362    }
363
364    #[tokio::test]
365    async fn test_invalid_input() {
366        let client = IsEvenApiClient::new();
367        for &a in INVALID_INPUT.iter() {
368            assert!(client.get(a).await.is_err());
369        }
370    }
371
372    // blocking tests
373    #[test]
374    #[cfg(feature = "blocking")]
375    fn test_valid_int_blocking() {
376        let client = IsEvenApiBlockingClient::new();
377        for (&a, b) in ODD_INTS.iter().zip(EVEN_INTS) {
378            assert!(client.get(a).unwrap().isodd());
379            assert!(client.get(b).unwrap().iseven());
380        }
381    }
382
383    #[test]
384    #[cfg(feature = "blocking")]
385    fn test_out_of_range_blocking() {
386        let client = IsEvenApiBlockingClient::new();
387        for &a in OUT_OF_RANGE_INTS.iter() {
388            assert!(client.get(a).is_err());
389        }
390    }
391
392    #[test]
393    #[cfg(feature = "blocking")]
394    fn test_invalid_input_blocking() {
395        let client = IsEvenApiBlockingClient::new();
396        for &a in INVALID_INPUT.iter() {
397            assert!(client.get(a).is_err());
398        }
399    }
400}