hcaptcha_no_wasm/
client.rs

1//!
2//! # Hcaptcha Client
3//!
4//! The Hcaptcha Client struct provides an http client to connect to
5//! the Hcaptcha API.
6//!
7//! The url for the API is stored in the url field of the struct.
8//! A default url is stored in the const VERIFY_URL.
9//! The new_with method allows the specification of an alternative url.
10//!
11//! # Examples
12//! Create client to connect to default API endpoint.
13//! ```
14//!     use hcaptcha_no_wasm::Client;
15//!     let client = Client::new();
16//! ```
17//!
18//! Create a client and submit for verification.
19//!```no_run
20//!     use hcaptcha_no_wasm::{Captcha, Client, Request};
21//!
22//! # #[tokio::main]
23//! # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
24//! #   let secret = get_your_secret();
25//! #   let captcha = dummy_captcha();
26//! #   let request = Request::new(&secret, captcha)?; // <- returns error
27//!     let client = Client::new();
28//!     let response = client.verify(request).await?;
29//! # Ok(())
30//! # }
31//!
32//! # fn get_your_secret() -> String {
33//! #   "0x123456789abcde0f123456789abcdef012345678".to_string()
34//! # }
35//! # use rand::distributions::Alphanumeric;
36//! # use rand::{thread_rng, Rng};
37//! # use std::iter;
38//! # fn random_response() -> String {
39//! #    let mut rng = thread_rng();
40//! #    iter::repeat(())
41//! #        .map(|()| rng.sample(Alphanumeric))
42//! #        .map(char::from)
43//! #        .take(100)
44//! #        .collect()
45//! # }
46//! # fn dummy_captcha() -> Captcha {
47//! #    Captcha::new(&random_response())
48//! #       .unwrap()
49//! #       .set_remoteip(&mockd::internet::ipv4_address())
50//! #       .unwrap()
51//! #       .set_sitekey(&mockd::unique::uuid_v4())
52//! #       .unwrap()
53//! #       }
54//!
55//! ```
56
57// const CYAN: &str = "\u{001b}[35m";
58// const RESET: &str = "\u{001b}[0m";
59
60use crate::Error;
61use crate::Request;
62use crate::Response;
63use reqwest::Url;
64
65mod form;
66
67use form::Form;
68
69/// Endpoint url for the Hcaptcha siteverify API.
70pub const VERIFY_URL: &str = "https://hcaptcha.com/siteverify";
71
72/// Client to submit a request to a Hcaptcha validation endpoint.
73#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
74#[derive(Debug)]
75pub struct Client {
76    /// HTTP Client to submit request to endpoint and read the response.
77    client: reqwest::Client,
78    /// Url for the endpoint.
79    url: Url,
80}
81
82#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
83impl Default for Client {
84    fn default() -> Client {
85        Client::new()
86    }
87}
88
89#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
90impl Client {
91    /// Create a new Hcaptcha Client to connect with the default Hcaptcha
92    /// siteverify API endpoint specified in [VERIFY_URL].
93    ///
94    /// # Example
95    /// Initialise client to connect to default API endpoint.
96    /// ```
97    ///     use hcaptcha_no_wasm::Client;
98    ///     let client = Client::new();
99    /// ```
100    /// # Panic
101    ///
102    /// If the default API url constant is corrupted the function with
103    /// will panic.
104    #[allow(unknown_lints)]
105    #[cfg_attr(docsrs, allow(rustdoc::bare_urls))]
106    pub fn new() -> Client {
107        Client {
108            client: reqwest::Client::new(),
109            url: Url::parse(VERIFY_URL).expect("API url string corrupt"),
110        }
111    }
112
113    /// Create a new Hcaptcha Client and specify the url for the API.
114    ///
115    /// Specify the url for the hcaptcha API.
116    ///
117    /// # Example
118    /// Initialise client to connect to custom Hcaptcha API
119    /// ```
120    ///     use hcaptcha_no_wasm::Client;
121    ///     use url::Url;
122    ///
123    ///     let url = "https://domain.com/siteverify";
124    ///     let _client = Client::new_with(url);
125    /// ```
126    pub fn new_with(url: &str) -> Result<Client, url::ParseError> {
127        Ok(Client {
128            client: reqwest::Client::new(),
129            url: Url::parse(url)?,
130        })
131    }
132
133    /// Set the url.
134    ///
135    /// Specify the url for the hcaptcha API. This method is useful
136    /// during testing to provide a mock url.
137    ///
138    /// # Example
139    /// Initialise client to connect to custom Hcaptcha API
140    /// ```no_run
141    /// # fn main() -> Result<(), hcaptcha_no_wasm::Error> {
142    ///     use hcaptcha_no_wasm::Client;
143    ///     use url::Url;
144    ///
145    ///     let url = "https://domain.com/siteverify";
146    ///     let client = Client::new()
147    ///                        .set_url(url)?;
148    /// #    Ok(())
149    /// # }
150    /// ```
151    pub fn set_url(mut self, url: &str) -> Result<Self, Error> {
152        self.url = Url::parse(url)?;
153        Ok(self)
154    }
155
156    /// Verify the client token with the Hcaptcha service API.
157    ///
158    /// Call the Hcaptcha api and provide a [Request] struct.
159    ///
160    /// # Inputs
161    ///
162    /// Request contains the required and optional fields
163    /// for the Hcaptcha API. The required fields include the response
164    /// code to validate and the secret key.
165    ///
166    /// # Outputs
167    ///
168    /// This method returns [Response] if successful and [Error] if
169    /// unsuccessful.
170    ///
171    /// # Example
172    ///
173    ///
174    ///  ```no_run
175    ///     use hcaptcha_no_wasm::{Client, Request};
176    /// # use hcaptcha_no_wasm::Captcha;
177    /// # use rand::distributions::Alphanumeric;
178    /// # use rand::{thread_rng, Rng};
179    /// # use std::iter;
180    /// # #[tokio::main]
181    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
182    ///     let secret = get_your_secret(); // your secret key
183    ///     let captcha = get_captcha();  // user's token
184    ///
185    ///     let request = Request::new(&secret, captcha)?;
186    ///
187    ///     let client = Client::new();
188    ///
189    ///     let response = client.verify_client_response(request).await?;
190    ///
191    /// # #[cfg(feature = "enterprise")]
192    ///     let score = response.score();
193    /// # #[cfg(feature = "enterprise")]
194    ///     let score_reasons = response.score_reason();
195    ///
196    /// # Ok(())
197    /// # }
198    /// # fn get_your_secret() -> String {
199    /// #   "0x123456789abcde0f123456789abcdef012345678".to_string()
200    /// # }
201    /// # fn random_response() -> String {
202    /// #    let mut rng = thread_rng();
203    /// #    iter::repeat(())
204    /// #        .map(|()| rng.sample(Alphanumeric))
205    /// #        .map(char::from)
206    /// #        .take(100)
207    /// #        .collect()
208    /// # }
209    /// # fn get_captcha() -> Captcha {
210    /// #    Captcha::new(&random_response())
211    /// #       .unwrap()
212    /// #       .set_remoteip(&mockd::internet::ipv4_address())
213    /// #       .unwrap()
214    /// #       .set_sitekey(&mockd::unique::uuid_v4())
215    /// #       .unwrap()
216    /// #       }
217    /// ```
218    ///
219    /// # Logging
220    ///
221    /// If the `trace` feature is enabled a debug level span is set for the
222    /// method and an event logs the response.
223    ///
224    #[allow(dead_code)]
225    #[cfg_attr(
226        feature = "trace",
227        tracing::instrument(
228            name = "Request verification from hcaptcha.",
229            skip(self),
230            level = "debug"
231        )
232    )]
233    #[deprecated(since = "3.0.0", note = "please use `verify` instead")]
234    pub async fn verify_client_response(self, request: Request) -> Result<Response, Error> {
235        let form: Form = request.into();
236        #[cfg(feature = "trace")]
237        tracing::debug!(
238            "The form to submit to Hcaptcha API: {:?}",
239            serde_urlencoded::to_string(&form).unwrap_or_else(|_| "form corrupted".to_owned())
240        );
241        let response = self
242            .client
243            .post(self.url.clone())
244            .form(&form)
245            .send()
246            .await?
247            .json::<Response>()
248            .await?;
249        #[cfg(feature = "trace")]
250        tracing::debug!("The response is: {:?}", response);
251        response.check_error()?;
252        Ok(response)
253    }
254
255    /// Verify the client token with the Hcaptcha service API.
256    ///
257    /// Call the Hcaptcha api and provide a [Request] struct.
258    ///
259    /// # Inputs
260    ///
261    /// Request contains the required and optional fields
262    /// for the Hcaptcha API. The required fields include the response
263    /// code to validate and the secret key.
264    ///
265    /// # Outputs
266    ///
267    /// This method returns [Response] if successful and [Error] if
268    /// unsuccessful.
269    ///
270    /// # Example
271    ///
272    ///
273    ///  ```no_run
274    ///     use hcaptcha_no_wasm::{Client, Request};
275    /// # use hcaptcha_no_wasm::Captcha;
276    /// # use rand::distributions::Alphanumeric;
277    /// # use rand::{thread_rng, Rng};
278    /// # use std::iter;
279    /// # #[tokio::main]
280    /// # async fn main() -> Result<(), hcaptcha_no_wasm::Error> {
281    ///     let secret = get_your_secret(); // your secret key
282    ///     let captcha = get_captcha();  // user's token
283    ///
284    ///     let request = Request::new(&secret, captcha)?;
285    ///
286    ///     let client = Client::new();
287    ///
288    ///     let response = client.verify(request).await?;
289    ///
290    /// # #[cfg(feature = "enterprise")]
291    ///     let score = response.score();
292    /// # #[cfg(feature = "enterprise")]
293    ///     let score_reasons = response.score_reason();
294    ///
295    /// # Ok(())
296    /// # }
297    /// # fn get_your_secret() -> String {
298    /// #   "0x123456789abcde0f123456789abcdef012345678".to_string()
299    /// # }
300    /// # fn random_response() -> String {
301    /// #    let mut rng = thread_rng();
302    /// #    iter::repeat(())
303    /// #        .map(|()| rng.sample(Alphanumeric))
304    /// #        .map(char::from)
305    /// #        .take(100)
306    /// #        .collect()
307    /// # }
308    /// # fn get_captcha() -> Captcha {
309    /// #    Captcha::new(&random_response())
310    /// #       .unwrap()
311    /// #       .set_remoteip(&mockd::internet::ipv4_address())
312    /// #       .unwrap()
313    /// #       .set_sitekey(&mockd::unique::uuid_v4())
314    /// #       .unwrap()
315    /// #       }
316    /// ```
317    ///
318    /// # Logging
319    ///
320    /// If the `trace` feature is enabled a debug level span is set for the
321    /// method and an event logs the response.
322    ///
323    #[allow(dead_code)]
324    #[cfg_attr(
325        feature = "trace",
326        tracing::instrument(
327            name = "Request verification from hcaptcha.",
328            skip(self),
329            level = "debug"
330        )
331    )]
332    pub async fn verify(self, request: Request) -> Result<Response, Error> {
333        let form: Form = request.into();
334        #[cfg(feature = "trace")]
335        tracing::debug!(
336            "The form to submit to Hcaptcha API: {:?}",
337            serde_urlencoded::to_string(&form).unwrap_or_else(|_| "form corrupted".to_owned())
338        );
339        let response = self
340            .client
341            .post(self.url.clone())
342            .form(&form)
343            .send()
344            .await?
345            .json::<Response>()
346            .await?;
347        #[cfg(feature = "trace")]
348        tracing::debug!("The response is: {:?}", response);
349        response.check_error()?;
350        Ok(response)
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::{Code, Error};
358    use chrono::{TimeDelta, Utc};
359    use claims::{assert_err, assert_ok};
360    use rand::distributions::Alphanumeric;
361    use rand::{thread_rng, Rng};
362    use serde_json::json;
363    use std::iter;
364    #[cfg(feature = "trace")]
365    use tracing_test::traced_test;
366    use wiremock::matchers::{body_string, method, path};
367    use wiremock::{Mock, MockServer, ResponseTemplate};
368
369    // const RED: &str = "\t\u{001b}[31m";
370    // const GREEN: &str = "\t\u{001b}[32m";
371    // const CYAN: &str = "\t\u{001b}[36m";
372    // const RESET: &str = "\t\u{001b}[0m";
373
374    fn random_string(characters: usize) -> String {
375        let mut rng = thread_rng();
376        iter::repeat(())
377            .map(|()| rng.sample(Alphanumeric))
378            .map(char::from)
379            .take(characters)
380            .collect()
381    }
382
383    #[tokio::test]
384    #[cfg_attr(feature = "trace", traced_test)]
385    async fn hcaptcha_mock_verify() {
386        let token = random_string(100);
387        let secret = format!("0x{}", hex::encode(random_string(20)));
388        let request = Request::new_from_response(&secret, &token).unwrap();
389
390        let expected_body = format!("response={}&secret={}", &token, &secret);
391
392        let timestamp = Utc::now()
393            .checked_sub_signed(TimeDelta::try_minutes(10).unwrap())
394            .unwrap()
395            .to_rfc3339();
396
397        let response_template = ResponseTemplate::new(200).set_body_json(json!({
398            "success": true,
399            "challenge_ts": timestamp,
400            "hostname": "test-host",
401        }));
402        let mock_server = MockServer::start().await;
403        Mock::given(method("POST"))
404            .and(path("/siteverify"))
405            .and(body_string(&expected_body))
406            .respond_with(response_template)
407            .mount(&mock_server)
408            .await;
409        let uri = format!("{}{}", mock_server.uri(), "/siteverify");
410
411        let client = Client::new_with(&uri).unwrap();
412        let response = client.verify(request).await;
413        assert_ok!(&response);
414        let response = response.unwrap();
415        assert!(&response.success());
416        assert_eq!(&response.timestamp().unwrap(), &timestamp);
417        #[cfg(feature = "trace")]
418        assert!(logs_contain("Hcaptcha API"));
419        #[cfg(feature = "trace")]
420        assert!(logs_contain("The response is"));
421    }
422
423    #[tokio::test]
424    #[cfg_attr(feature = "trace", traced_test)]
425    async fn hcaptcha_mock_verify_not_found() {
426        let token = random_string(100);
427        let secret = format!("0x{}", hex::encode(random_string(20)));
428        let request = Request::new_from_response(&secret, &token).unwrap();
429
430        let expected_body = format!("response={}&secret={}", &token, &secret);
431
432        let response_template = ResponseTemplate::new(404);
433        let mock_server = MockServer::start().await;
434        Mock::given(method("POST"))
435            .and(path("/siteverify"))
436            .and(body_string(&expected_body))
437            .respond_with(response_template)
438            .mount(&mock_server)
439            .await;
440        let uri = format!("{}{}", mock_server.uri(), "/siteverify");
441
442        let client = Client::new_with(&uri).unwrap();
443        let response = client.verify(request).await;
444        assert_err!(&response);
445    }
446
447    #[tokio::test]
448    #[cfg_attr(feature = "trace", traced_test)]
449    async fn hcaptcha_mock_verify_client_response() {
450        let token = random_string(100);
451        let secret = format!("0x{}", hex::encode(random_string(20)));
452        let request = Request::new_from_response(&secret, &token).unwrap();
453
454        let expected_body = format!("response={}&secret={}", &token, &secret);
455
456        let timestamp = Utc::now()
457            .checked_sub_signed(TimeDelta::try_minutes(10).unwrap())
458            .unwrap()
459            .to_rfc3339();
460
461        let response_template = ResponseTemplate::new(200).set_body_json(json!({
462            "success": true,
463            "challenge_ts": timestamp,
464            "hostname": "test-host",
465        }));
466        let mock_server = MockServer::start().await;
467        Mock::given(method("POST"))
468            .and(path("/siteverify"))
469            .and(body_string(&expected_body))
470            .respond_with(response_template)
471            .mount(&mock_server)
472            .await;
473        let uri = format!("{}{}", mock_server.uri(), "/siteverify");
474
475        let client = Client::new_with(&uri).unwrap();
476        #[allow(deprecated)]
477        let response = client.verify_client_response(request).await;
478        assert_ok!(&response);
479        let response = response.unwrap();
480        assert!(&response.success());
481        assert_eq!(&response.timestamp().unwrap(), &timestamp);
482        #[cfg(feature = "trace")]
483        assert!(logs_contain("Hcaptcha API"));
484        #[cfg(feature = "trace")]
485        assert!(logs_contain("The response is"));
486    }
487
488    #[tokio::test]
489    #[cfg_attr(feature = "trace", traced_test)]
490    async fn hcaptcha_mock_verify_client_response_not_found() {
491        let token = random_string(100);
492        let secret = format!("0x{}", hex::encode(random_string(20)));
493        let request = Request::new_from_response(&secret, &token).unwrap();
494
495        let expected_body = format!("response={}&secret={}", &token, &secret);
496
497        let response_template = ResponseTemplate::new(404);
498        let mock_server = MockServer::start().await;
499        Mock::given(method("POST"))
500            .and(path("/siteverify"))
501            .and(body_string(&expected_body))
502            .respond_with(response_template)
503            .mount(&mock_server)
504            .await;
505        let uri = format!("{}{}", mock_server.uri(), "/siteverify");
506
507        let client = Client::new_with(&uri).unwrap();
508        #[allow(deprecated)]
509        let response = client.verify_client_response(request).await;
510        assert_err!(&response);
511    }
512
513    #[tokio::test]
514    #[cfg_attr(feature = "trace", traced_test)]
515    async fn hcaptcha_mock_with_remoteip() {
516        let token = random_string(100);
517        let secret = format!("0x{}", hex::encode(random_string(20)));
518        let remoteip = mockd::internet::ipv4_address();
519        let request = Request::new_from_response(&secret, &token)
520            .unwrap()
521            .set_remoteip(&remoteip)
522            .unwrap();
523
524        let expected_body = format!(
525            "response={}&remoteip={}&secret={}",
526            &token, &remoteip, &secret
527        );
528
529        let timestamp = Utc::now()
530            .checked_sub_signed(TimeDelta::try_minutes(10).unwrap())
531            .unwrap()
532            .to_rfc3339();
533
534        let response_template = ResponseTemplate::new(200).set_body_json(json!({
535            "success": true,
536            "challenge_ts": timestamp,
537            "hostname": "test-host",
538        }));
539        let mock_server = MockServer::start().await;
540        Mock::given(method("POST"))
541            .and(path("/siteverify"))
542            .and(body_string(&expected_body))
543            .respond_with(response_template)
544            .mount(&mock_server)
545            .await;
546        let uri = format!("{}{}", mock_server.uri(), "/siteverify");
547
548        let client = Client::new_with(&uri).unwrap();
549        let response = client.verify(request).await;
550        assert_ok!(&response);
551        let response = response.unwrap();
552        assert!(&response.success());
553        assert_eq!(&response.timestamp().unwrap(), &timestamp);
554        #[cfg(feature = "trace")]
555        assert!(logs_contain("Hcaptcha API"));
556        #[cfg(feature = "trace")]
557        assert!(logs_contain("The response is"));
558    }
559
560    #[tokio::test]
561    #[cfg_attr(feature = "trace", traced_test)]
562    async fn hcaptcha_mock_with_sitekey() {
563        let token = random_string(100);
564        let secret = format!("0x{}", hex::encode(random_string(20)));
565        let sitekey = mockd::unique::uuid_v4();
566        let request = Request::new_from_response(&secret, &token)
567            .unwrap()
568            .set_sitekey(&sitekey)
569            .unwrap();
570
571        let expected_body = format!(
572            "response={}&sitekey={}&secret={}",
573            &token, &sitekey, &secret
574        );
575
576        let timestamp = Utc::now()
577            .checked_sub_signed(TimeDelta::try_minutes(10).unwrap())
578            .unwrap()
579            .to_rfc3339();
580
581        let response_template = ResponseTemplate::new(200).set_body_json(json!({
582            "success": true,
583            "challenge_ts": timestamp,
584            "hostname": "test-host",
585        }));
586        let mock_server = MockServer::start().await;
587        Mock::given(method("POST"))
588            .and(path("/siteverify"))
589            .and(body_string(&expected_body))
590            .respond_with(response_template)
591            .mount(&mock_server)
592            .await;
593        let uri = format!("{}{}", mock_server.uri(), "/siteverify");
594
595        let client = Client::new_with(&uri).unwrap();
596        let response = client.verify(request).await;
597        assert_ok!(&response);
598        let response = response.unwrap();
599        assert!(&response.success());
600        assert_eq!(&response.timestamp().unwrap(), &timestamp);
601        #[cfg(feature = "trace")]
602        assert!(logs_contain("Hcaptcha API"));
603        #[cfg(feature = "trace")]
604        assert!(logs_contain("The response is"));
605    }
606
607    #[test]
608    fn test_success_response() {
609        let api_response = json!({
610            "success": true,
611            "challenge_ts": "2020-11-11T23:27:00Z",
612            "hostname": "my-host.ie",
613            "credit": true,
614            "error-codes": [],
615            "score": null,
616            "score_reason": [],
617        });
618        let response: Response = serde_json::from_value(api_response).unwrap();
619        assert!(response.success());
620        assert_eq!(
621            response.timestamp(),
622            Some("2020-11-11T23:27:00Z".to_owned())
623        );
624        assert_eq!(response.hostname(), Some("my-host.ie".to_owned()));
625    }
626
627    #[test]
628    fn test_error_response() {
629        let api_response = json!({
630            "success": false,
631            "challenge_ts": null,
632            "hostname": null,
633            "credit": null,
634            "error-codes": ["missing-input-secret", "foo"],
635            "score": null,
636            "score_reason": [],
637        });
638        let response: Response = serde_json::from_value(api_response).unwrap();
639        assert!(!response.success());
640        assert!(response.error_codes().is_some());
641        if let Some(hash_set) = response.error_codes() {
642            assert_eq!(hash_set.len(), 2);
643            assert!(hash_set.contains(&Code::MissingSecret));
644            assert!(hash_set.contains(&Code::Unknown("foo".to_owned())));
645        }
646    }
647
648    #[test]
649    fn test_hcaptcha_client_default_initialization() {
650        let client = Client::default();
651        assert!(matches!(client, Client { .. }));
652    }
653
654    #[test]
655    fn test_hcaptcha_client_default_calls_new() {
656        // Assuming Client::new() has some side effect or state change
657        // that can be checked to ensure it was called.
658        let client = Client::default();
659        // Here we would check the side effect or state change
660        // For example, if new() sets a specific field, we would assert that field's value
661        let expected_value = Url::parse(VERIFY_URL).unwrap();
662        assert!(client.url == expected_value);
663    }
664
665    #[test]
666    fn test_set_url_with_valid_url() {
667        let client = Client::default();
668        let result = client.set_url("https://example.com");
669        assert!(result.is_ok());
670        assert_eq!(result.unwrap().url.as_str(), "https://example.com/");
671    }
672
673    #[test]
674    fn test_set_url_with_invalid_url() {
675        let client = Client::default();
676        let result = client.set_url("invalid-url");
677        assert!(result.is_err());
678        match result {
679            Err(Error::Url(_)) => (),
680            _ => panic!("Expected UrlParseError"),
681        }
682    }
683
684    #[test]
685    fn test_set_url_with_empty_string() {
686        let client = Client::default();
687        let result = client.set_url("");
688        assert!(result.is_err());
689        match result {
690            Err(Error::Url(_)) => (),
691            _ => panic!("Expected UrlParseError"),
692        }
693    }
694}