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}