ddclient_rs/
client.rs

1// Copyright (c) 2023, Direct Decisions Rust client AUTHORS.
2// All rights reserved.
3// Use of this source code is governed by a BSD-style
4// license that can be found in the LICENSE file.
5
6use crate::{
7    handle_api_response, ApiError, ClientError, Rate, Voting, VotingResults, CONTENT_TYPE,
8    DEFAULT_BASE_URL, USER_AGENT,
9};
10
11use reqwest::{Method, Response};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15
16#[derive(Debug, Serialize, Deserialize)]
17struct VotingRequest {
18    choices: Vec<String>,
19}
20
21#[derive(Debug, Serialize, Deserialize)]
22struct SetChoiceRequest {
23    choice: String,
24    index: i32,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
28struct SetChoiceResponse {
29    choices: Vec<String>,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33struct VoteResponse {
34    revoted: bool,
35}
36
37#[derive(Debug, Serialize, Deserialize)]
38struct Ballot {
39    ballot: HashMap<String, i32>,
40}
41
42#[derive(Debug, Serialize, Deserialize)]
43struct OkResponse {
44    code: i32,
45    message: String,
46}
47
48/// A client for accessing the Direct Decisions API.
49///
50/// This struct provides methods to interact with various endpoints of the
51/// Direct Decisions API, including creating votings, voting, and fetching results.
52/// The api specification can be found at https://api.directdecisions.com/v1.
53/// All possible Error responses are described in the ApiError enum and the above documentation.
54///
55/// # Examples
56///
57/// ```no_run
58/// use ddclient_rs::Client;
59///
60/// #[tokio::main]
61/// async fn main() {
62///     let client = Client::new("my-api-key".to_string());
63///     // Use client to interact with the API...
64/// }
65/// ```
66
67pub struct Client {
68    token: String,
69    client: reqwest::Client,
70    api_url: String,
71    rate: Arc<Mutex<Option<Rate>>>,
72}
73
74impl Client {
75    /// Constructs a new `Client` with the given API token, and the default API URL.
76    /// The default API URL is `https://api.directdecisions.com`.
77    /// If you need to use a custom API URL, use `Client::builder` instead.
78    /// The default Reqwest client is created. If you need to use a custom Reqwest client,
79    /// use `Client::builder` instead.
80    /// Client parses and stores received rate limit information which is updated after each request.
81    /// To access the rate limit information, use `Client::get_rate`.
82    ///
83    /// # Arguments
84    ///
85    /// * `token` - The API token used for authenticating with the Direct Decisions API.
86    ///
87    /// # Examples
88    ///
89    /// ```no_run
90    /// use ddclient_rs::Client;
91    ///
92    /// let client = Client::new("my-api-key".to_string());
93    /// ```
94    pub fn new(token: String) -> Self {
95        Self::builder(token).build()
96    }
97
98    /// Creates a new `ClientBuilder` for constructing a `Client`.
99    ///
100    /// This method initializes a builder with the provided API token.
101    /// Additional configurations, such as a custom API URL or Reqwest client,
102    /// can be set using the builder's methods before building the `Client`.
103    ///
104    /// # Examples
105    ///
106    /// Basic usage:
107    ///
108    /// ```
109    /// use ddclient_rs::Client;
110    ///
111    /// let client = Client::builder("my-api-key".to_string())
112    ///     .build();
113    /// ```
114    ///
115    /// Advanced usage with custom configurations:
116    ///
117    /// ```
118    /// use ddclient_rs::Client;
119    ///
120    /// let client = Client::builder("my-api-key".to_string())
121    ///     .api_url("https://custom-api.directdecisions.com".to_string())
122    ///     .build();
123    /// ```
124    pub fn builder(token: String) -> ClientBuilder {
125        ClientBuilder::new(token)
126    }
127
128    /// Retrieves the current rate limit information.
129    ///
130    /// This method returns the most recent rate limit information as received
131    /// from the Direct Decisions API, if available.
132    /// If no rate limit information is available, `None` is returned.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use ddclient_rs::Client;
138    ///
139    /// #[tokio::main]
140    /// async fn main() {
141    ///     let client = Client::builder("my-api-key".to_string())
142    ///         .build();
143    ///
144    ///     if let Some(rate) = client.get_rate() {
145    ///         println!("Current rate limit: {:?}", rate);
146    ///     } else {
147    ///         println!("No rate limit information available.");
148    ///     }
149    /// }
150    /// ```
151    pub fn get_rate(&self) -> Option<Rate> {
152        let rate = self.rate.lock().unwrap();
153        rate.clone()
154    }
155
156    async fn request<T: serde::Serialize>(
157        &self,
158        method: Method,
159        path: &str,
160        body: Option<T>,
161    ) -> Result<Response, ClientError> {
162        let url = format!("{}{}", self.api_url, path);
163
164        let mut request = self
165            .client
166            .request(method, url)
167            .header("Authorization", format!("Bearer {}", self.token))
168            .header("Accept", CONTENT_TYPE)
169            .header("User-Agent", USER_AGENT);
170
171        if let Some(b) = body {
172            request = request.header("Content-Type", CONTENT_TYPE);
173            request = request.json(&b);
174        }
175
176        let response = request
177            .send()
178            .await
179            .map_err(|err| ClientError::HttpRequestError(err.without_url()));
180
181        if let Ok(response) = &response {
182            let rate_update = Rate::from_headers(response.headers());
183            let mut rate = self.rate.lock().unwrap();
184            *rate = rate_update;
185        }
186
187        response
188    }
189
190    /// Creates a new voting.
191    ///
192    /// Sends a POST request to the Direct Decisions API to create a new voting
193    /// with the specified choices.
194    ///
195    /// Returns a `Result` which is `Ok` containing the created `Voting` if successful,
196    /// or an `Err` with an `ApiError` if the request fails.
197    ///
198    /// # Examples
199    ///
200    /// ```no_run
201    /// use ddclient_rs::Client;
202    ///
203    /// #[tokio::main]
204    /// async fn main() {
205    ///     let client = Client::builder("my-api-key".to_string()).build();
206    ///     let result = client.create_voting(vec!["Option 1".into(), "Option 2".into()]).await;
207    ///     // Handle result...
208    /// }
209    /// ```
210    pub async fn create_voting(&self, choices: Vec<String>) -> Result<Voting, ApiError> {
211        let response = self
212            .request(Method::POST, "v1/votings", Some(VotingRequest { choices }))
213            .await?;
214
215        handle_api_response(response).await
216    }
217
218    /// Retrieves a voting by its ID.
219    ///
220    /// Returns a `Result` which is `Ok` containing the `Voting` if found,
221    /// or an `Err` with an `ApiError` if the voting is not found or the request fails.
222    pub async fn get_voting(&self, id: &str) -> Result<Voting, ApiError> {
223        let mut uri = "v1/votings/".to_string();
224        url_escape::encode_path_to_string(id, &mut uri);
225
226        let response = self.request::<Voting>(Method::GET, &uri, None).await?;
227
228        handle_api_response(response).await
229    }
230
231    /// Deletes a voting by its ID.
232    ///
233    /// Returns a `Result` which is `Ok` if the voting was deleted successfully,
234    /// or an `Err` with an `ApiError` if the voting is not found or the request fails.
235    pub async fn delete_voting(&self, id: &str) -> Result<(), ApiError> {
236        let mut uri = "v1/votings/".to_string();
237        url_escape::encode_path_to_string(id, &mut uri);
238
239        let response = self
240            .request::<OkResponse>(Method::DELETE, &uri, None)
241            .await?;
242
243        let _ = handle_api_response::<OkResponse>(response).await?;
244
245        Ok(())
246    }
247
248    /// Sets or updates a choice in a voting.
249    //////
250    /// This endpoint combines all possible modifications of the choices list elements.
251    /// To add a new choice, provide its value as a string and an index where it should be placed in the list. For example, index 0 will append a new choice, while index equal to the number of choices will prepend it. For any other index number between, the choice will be inserted at that position.
252    /// To remove a choice, provide the exact choice value as the string and set index to -1 value.
253    /// To move an existing choice to a new position, provide the exact choice value as the string and its new position as the index.
254    ///
255    /// Returns a `Result` with the updated list of choices if successful,
256    /// or an `Err` with an `ApiError` if the request fails.
257    /// # Examples
258    ///
259    /// ```no_run
260    /// use ddclient_rs::Client;
261    ///
262    /// #[tokio::main]
263    /// async fn main() {
264    ///     let client = Client::builder("my-api-key".to_string()).build();
265    ///     let result = client.set_choice("voting_id", "New Choice", 0).await;
266    ///     // Handle result...
267    /// }
268    /// ```
269    pub async fn set_choice(
270        &self,
271        voting_id: &str,
272        choice: &str,
273        index: i32,
274    ) -> Result<Vec<String>, ApiError> {
275        let mut uri = "v1/votings/".to_string();
276        url_escape::encode_path_to_string(voting_id, &mut uri);
277        uri.push_str("/choices");
278
279        let response = self
280            .request(
281                Method::POST,
282                &uri,
283                Some(SetChoiceRequest {
284                    choice: choice.to_string(),
285                    index,
286                }),
287            )
288            .await?;
289
290        let resp = handle_api_response::<SetChoiceResponse>(response).await?;
291
292        Ok(resp.choices)
293    }
294
295    /// Submits a vote on a specific voting.
296    ///
297    /// Votes are submitted as a ballot, which is a map of choices to their ranks.
298    /// The ranks are integers starting from 1, where 1 is the highest rank.
299    /// Not all choices need to be included in the ballot.
300    ///
301    /// Returns a `Result` which is `Ok` indicating whether the vote was a revote,
302    /// or an `Err` with an `ApiError` if the voting is not found or the request fails.
303    ///
304
305    /// # Examples
306    ///
307    /// ```
308    /// use ddclient_rs::Client;
309    /// use std::collections::HashMap;
310    ///
311    /// #[tokio::main]
312    /// async fn main() {
313    ///     let client = Client::builder("my-api-key".to_string()).build();
314    ///     let ballot = HashMap::from([
315    ///         ("Choice 1".to_string(), 1),
316    ///         ("Choice 2".to_string(), 2),
317    ///     ]);
318    ///     let result = client.vote("voting_id", "voter_id", ballot).await;
319    ///     // Handle result...
320    /// }
321    /// ```
322    pub async fn vote(
323        &self,
324        voting_id: &str,
325        voter_id: &str,
326        ballot: HashMap<String, i32>,
327    ) -> Result<bool, ApiError> {
328        let mut uri = "v1/votings/".to_string();
329        url_escape::encode_path_to_string(voting_id, &mut uri);
330        uri.push_str("/ballots/");
331        url_escape::encode_path_to_string(voter_id, &mut uri);
332
333        let response = self
334            .request(Method::POST, &uri, Some(Ballot { ballot }))
335            .await?;
336
337        let response = handle_api_response::<VoteResponse>(response).await?;
338
339        Ok(response.revoted)
340    }
341
342    /// Removes a voter's ballot from a specific voting.
343    pub async fn unvote(&self, voting_id: &str, voter_id: &str) -> Result<(), ApiError> {
344        let mut uri = "v1/votings/".to_string();
345        url_escape::encode_path_to_string(voting_id, &mut uri);
346        uri.push_str("/ballots/");
347        url_escape::encode_path_to_string(voter_id, &mut uri);
348
349        let response = self
350            .request::<OkResponse>(Method::DELETE, &uri, None)
351            .await?;
352
353        let _ = handle_api_response::<OkResponse>(response).await?;
354
355        Ok(())
356    }
357
358    /// Retrieves a ballot for a specific voting and voter.
359    /// The ballot is returned as a map of choices to their ranks.
360    /// The ranks are integers starting from 1, where 1 is the highest rank.
361    pub async fn get_ballot(
362        &self,
363        voting_id: &str,
364        voter_id: &str,
365    ) -> Result<HashMap<String, i32>, ApiError> {
366        let mut uri = "v1/votings/".to_string();
367        url_escape::encode_path_to_string(voting_id, &mut uri);
368        uri.push_str("/ballots/");
369        url_escape::encode_path_to_string(voter_id, &mut uri);
370
371        let response = self.request::<Ballot>(Method::GET, &uri, None).await?;
372
373        let response = handle_api_response::<Ballot>(response).await?;
374
375        Ok(response.ballot)
376    }
377
378    /// Retrieves the results of a specific voting.
379    /// The results are returned as a list of choices with their wins, percentage, and index.
380    /// It does not include the duels information.
381    pub async fn get_voting_results(&self, voting_id: &str) -> Result<VotingResults, ApiError> {
382        let mut uri = "v1/votings/".to_string();
383        url_escape::encode_path_to_string(voting_id, &mut uri);
384        uri.push_str("/results");
385
386        let response = self
387            .request::<VotingResults>(Method::GET, &uri, None)
388            .await?;
389
390        handle_api_response(response).await
391    }
392
393    /// Retrieves the results of a specific voting.
394    /// The results are returned as a list of choices with their wins, percentage, and index.
395    /// The results also include the duels information between choices.
396    pub async fn get_voting_results_duels(
397        &self,
398        voting_id: &str,
399    ) -> Result<VotingResults, ApiError> {
400        let mut uri = "v1/votings/".to_string();
401        url_escape::encode_path_to_string(voting_id, &mut uri);
402        uri.push_str("/results/duels");
403
404        let response = self
405            .request::<VotingResults>(Method::GET, &uri, None)
406            .await?;
407
408        handle_api_response(response).await
409    }
410}
411
412/// A builder for creating an instance of `Client`.
413///
414/// This builder allows for configuring optional parameters for `Client`,
415/// such as a custom API URL or a custom Reqwest client.
416///
417/// # Examples
418///
419/// ```
420/// use ddclient_rs::{Client, ClientBuilder};
421///
422/// let client = Client::builder("my-api-key".to_string())
423///     .api_url("https://custom-api.directdecisions.com".to_string())
424///     .build();
425/// ```
426pub struct ClientBuilder {
427    token: String,
428    api_url: Option<String>,
429    reqwest_client: Option<reqwest::Client>,
430}
431
432impl ClientBuilder {
433    fn new(token: String) -> Self {
434        ClientBuilder {
435            token,
436            api_url: None,
437            reqwest_client: None,
438        }
439    }
440
441    /// Sets a custom API URL for the `Client`.
442    ///
443    /// If not set, a default URL is used.
444    ///
445    /// # Arguments
446    ///
447    /// * `api_url` - A string representing the custom API URL.
448    pub fn api_url(mut self, api_url: String) -> Self {
449        self.api_url = Some(api_url);
450        self
451    }
452
453    /// Sets a custom Reqwest client for the `Client`.
454    ///
455    /// If not set, a default Reqwest client is used.
456    ///
457    /// # Arguments
458    ///
459    /// * `client` - An instance of `reqwest::Client` to be used with the `Client`.
460    pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
461        self.reqwest_client = Some(client);
462        self
463    }
464
465    /// Builds and returns a new `Client` instance.
466    ///
467    /// This method consumes the builder, applies URL validation and formatting,
468    /// and uses the provided configurations to create a `Client`.
469    /// If certain configurations are not provided, default values are used.
470    ///
471    /// # Panics
472    ///
473    /// Panics if the provided API URL is invalid.
474    ///
475    /// # Returns
476    ///
477    /// Returns a `Client` instance with the configured options.
478    ///
479    /// # Examples
480    ///
481    /// ```
482    /// use ddclient_rs::Client;
483    ///
484    /// let client = Client::builder("my-api-key".to_string())
485    ///     .api_url("https://custom-api.directdecisions.com".to_string())
486    ///     .build();
487    /// ```
488    pub fn build(self) -> Client {
489        let mut api_url = match self.api_url {
490            Some(url) => {
491                let _ = reqwest::Url::parse(&url).expect("Invalid API URL");
492                url
493            }
494            None => DEFAULT_BASE_URL.to_string(),
495        };
496
497        if !api_url.ends_with('/') {
498            api_url.push('/');
499        }
500
501        let client = self.reqwest_client.unwrap_or_default();
502
503        Client {
504            token: self.token,
505            client,
506            api_url,
507            rate: Arc::new(Mutex::new(None)),
508        }
509    }
510}