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}