what3words_api/
service.rs

1use crate::models::{
2    autosuggest::{Autosuggest, AutosuggestResult, AutosuggestSelection},
3    error::ErrorResult,
4    gridsection::{BoundingBox, FormattedGridSection},
5    language::AvailableLanguages,
6    location::{ConvertTo3wa, ConvertToCoordinates, FormattedAddress},
7};
8use http::{HeaderMap, HeaderName, HeaderValue};
9use regex::Regex;
10#[cfg(feature = "sync")]
11use reqwest::blocking::Client;
12#[cfg(not(feature = "sync"))]
13use reqwest::Client;
14use serde::de::DeserializeOwned;
15use std::{collections::HashMap, env, fmt};
16
17pub(crate) trait Validator {
18    fn validate(&self) -> std::result::Result<(), Error>;
19}
20
21pub(crate) trait ToHashMap {
22    fn to_hash_map<'a>(&self) -> std::result::Result<HashMap<&'a str, String>, Error>;
23}
24
25#[derive(Debug)]
26pub enum Error {
27    Network(String),
28    Http(String),
29    Api(String, String),
30    Decode(String),
31    InvalidParameter(&'static str),
32    Unknown(String),
33}
34
35impl fmt::Display for Error {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Error::Network(msg) => write!(f, "Network error: {}", msg),
39            Error::Http(msg) => write!(f, "HTTP error: {}", msg),
40            Error::Api(code, message) => {
41                write!(f, "W3W error: {} {}", code, message)
42            }
43            Error::Decode(msg) => write!(f, "Decode error: {}", msg),
44            Error::InvalidParameter(msg) => write!(f, "Invalid input: {}", msg),
45            Error::Unknown(msg) => write!(f, "Unknown error: {}", msg),
46        }
47    }
48}
49
50impl std::error::Error for Error {}
51
52impl From<reqwest::Error> for Error {
53    fn from(error: reqwest::Error) -> Self {
54        if error.is_request() {
55            Error::Http(error.to_string())
56        } else if error.is_connect() {
57            Error::Network(error.to_string())
58        } else if error.is_decode() {
59            Error::Decode(error.to_string())
60        } else {
61            Error::Unknown(error.to_string())
62        }
63    }
64}
65
66pub(crate) type Result<T> = std::result::Result<T, Error>;
67
68const DEFAULT_W3W_API_BASE_URL: &str = "https://api.what3words.com/v3";
69const HEADER_WHAT3WORDS_API_KEY: &str = "X-Api-Key";
70const W3W_WRAPPER: &str = "X-W3W-Wrapper";
71
72pub struct What3words {
73    api_key: String,
74    host: String,
75    headers: HeaderMap,
76    user_agent: String,
77}
78
79impl What3words {
80    pub fn new(api_key: impl Into<String>) -> Self {
81        Self {
82            api_key: api_key.into(),
83            headers: HeaderMap::new(),
84            host: DEFAULT_W3W_API_BASE_URL.into(),
85            user_agent: format!(
86                "what3words-rust/{} ({})",
87                env!("CARGO_PKG_VERSION"),
88                env::consts::OS
89            ),
90        }
91    }
92
93    pub fn header<K, V>(mut self, key: K, value: V) -> Self
94    where
95        HeaderName: TryFrom<K>,
96        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
97        HeaderValue: TryFrom<V>,
98        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
99    {
100        if let (Ok(header_name), Ok(header_value)) =
101            (HeaderName::try_from(key), HeaderValue::try_from(value))
102        {
103            self.headers.insert(header_name, header_value);
104        }
105        self
106    }
107
108    pub fn hostname(mut self, host: impl Into<String>) -> Self {
109        self.host = host.into();
110        self
111    }
112
113    #[cfg(feature = "sync")]
114    pub fn convert_to_3wa<T: FormattedAddress + DeserializeOwned>(
115        &self,
116        options: &ConvertTo3wa,
117    ) -> Result<T> {
118        let url = format!("{}/convert-to-3wa", self.host);
119        let mut params = options.to_hash_map()?;
120        params.insert("format", T::format().to_string());
121        self.request(url, Some(params))
122    }
123
124    #[cfg(not(feature = "sync"))]
125    pub async fn convert_to_3wa<T: FormattedAddress + DeserializeOwned>(
126        &self,
127        options: &ConvertTo3wa,
128    ) -> Result<T> {
129        let url = format!("{}/convert-to-3wa", self.host);
130        let mut params = options.to_hash_map()?;
131        params.insert("format", T::format().to_string());
132        self.request(url, Some(params)).await
133    }
134
135    #[cfg(feature = "sync")]
136    pub fn convert_to_coordinates<T: FormattedAddress + DeserializeOwned>(
137        &self,
138        options: &ConvertToCoordinates,
139    ) -> Result<T> {
140        let url = format!("{}/convert-to-coordinates", self.host);
141        let mut params = options.to_hash_map()?;
142        params.insert("format", T::format().to_string());
143        self.request(url, Some(params))
144    }
145
146    #[cfg(not(feature = "sync"))]
147    pub async fn convert_to_coordinates<T: FormattedAddress + DeserializeOwned>(
148        &self,
149        options: &ConvertToCoordinates,
150    ) -> Result<T> {
151        let url = format!("{}/convert-to-coordinates", self.host);
152        let mut params = options.to_hash_map()?;
153        params.insert("format", T::format().to_string());
154        self.request(url, Some(params)).await
155    }
156
157    #[cfg(feature = "sync")]
158    pub fn available_languages(&self) -> Result<AvailableLanguages> {
159        let url = format!("{}/available-languages", self.host);
160        self.request(url, None)
161    }
162
163    #[cfg(not(feature = "sync"))]
164    pub async fn available_languages(&self) -> Result<AvailableLanguages> {
165        let url = format!("{}/available-languages", self.host);
166        self.request(url, None).await
167    }
168
169    #[cfg(feature = "sync")]
170    pub fn grid_section<T: DeserializeOwned + FormattedGridSection>(
171        &self,
172        bounding_box: &BoundingBox,
173    ) -> Result<T> {
174        let mut params = HashMap::new();
175        params.insert("bounding-box", bounding_box.to_string());
176        let url = format!("{}/grid-section", self.host);
177        params.insert("format", T::format().to_string());
178        self.request(url, Some(params))
179    }
180
181    #[cfg(not(feature = "sync"))]
182    pub async fn grid_section<T: DeserializeOwned + FormattedGridSection>(
183        &self,
184        bounding_box: &BoundingBox,
185    ) -> Result<T> {
186        let mut params = HashMap::new();
187        params.insert("bounding-box", bounding_box.to_string());
188        let url = format!("{}/grid-section", self.host);
189        params.insert("format", T::format().to_string());
190        self.request(url, Some(params)).await
191    }
192
193    #[cfg(feature = "sync")]
194    pub fn autosuggest(&self, autosuggest: &Autosuggest) -> Result<AutosuggestResult> {
195        let params = autosuggest.clone().to_hash_map()?;
196        let url = format!("{}/autosuggest", self.host);
197        self.request(url, Some(params))
198    }
199
200    #[cfg(not(feature = "sync"))]
201    pub async fn autosuggest(&self, autosuggest: &Autosuggest) -> Result<AutosuggestResult> {
202        let params = autosuggest.clone().to_hash_map()?;
203        let url = format!("{}/autosuggest", self.host);
204        self.request(url, Some(params)).await
205    }
206
207    #[cfg(feature = "sync")]
208    pub fn autosuggest_with_coordinates(
209        &self,
210        autosuggest: &Autosuggest,
211    ) -> Result<AutosuggestResult> {
212        let params = autosuggest.clone().to_hash_map()?;
213        let url = format!("{}/autosuggest-with-coordinates", self.host);
214        self.request(url, Some(params))
215    }
216
217    #[cfg(not(feature = "sync"))]
218    pub async fn autosuggest_with_coordinates(
219        &self,
220        autosuggest: &Autosuggest,
221    ) -> Result<AutosuggestResult> {
222        let params = autosuggest.clone().to_hash_map()?;
223        let url = format!("{}/autosuggest-with-coordinates", self.host);
224        self.request(url, Some(params)).await
225    }
226
227    #[cfg(feature = "sync")]
228    pub fn autosuggest_selection(&self, selection: &AutosuggestSelection) -> Result<()> {
229        let params = selection.to_hash_map()?;
230        let url = format!("{}/autosuggest-selection", self.host);
231        self.request(url, Some(params))
232    }
233
234    #[cfg(not(feature = "sync"))]
235    pub async fn autosuggest_selection(&self, selection: &AutosuggestSelection) -> Result<()> {
236        let params = selection.to_hash_map()?;
237        let url = format!("{}/autosuggest-selection", self.host);
238        self.request(url, Some(params)).await
239    }
240
241    #[cfg(feature = "sync")]
242    pub fn is_valid_3wa(&self, input: impl Into<String>) -> bool {
243        let input_str = input.into();
244        if self.is_possible_3wa(&input_str) {
245            if let Ok(suggestion) = self.autosuggest(&Autosuggest::new(&input_str).n_results("1")) {
246                return suggestion
247                    .suggestions
248                    .first()
249                    .map_or(false, |suggestion| suggestion.words == input_str);
250            }
251        }
252        false
253    }
254
255    #[cfg(not(feature = "sync"))]
256    pub async fn is_valid_3wa(&self, input: impl Into<String>) -> bool {
257        let input_str = input.into();
258        if self.is_possible_3wa(&input_str) {
259            if let Ok(suggestion) = self
260                .autosuggest(&Autosuggest::new(&input_str).n_results("1"))
261                .await
262            {
263                return suggestion
264                    .suggestions
265                    .first()
266                    .map_or(false, |suggestion| suggestion.words == input_str);
267            }
268        }
269        false
270    }
271
272    pub fn did_you_mean(&self, input: impl Into<String>) -> bool {
273        let pattern = Regex::new(
274            r#"^/?[^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}[.\uFF61\u3002\uFF65\u30FB\uFE12\u17D4\u0964\u1362\u3002:။^_۔։ ,\\/+'&\\:;|\u3000-]{1,2}[^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}[.\uFF61\u3002\uFF65\u30FB\uFE12\u17D4\u0964\u1362\u3002:။^_۔։ ,\\/+'&\\:;|\u3000-]{1,2}[^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}$"#,
275        ).unwrap();
276        pattern.is_match(&input.into())
277    }
278
279    pub fn is_possible_3wa(&self, input: impl Into<String>) -> bool {
280        let pattern = Regex::new(
281            r#"^/*(?:[^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}[.。。・・︒។։။۔።।][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}[.。。・・︒។։။۔።।][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}|[^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}([\u0020\u00A0][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]+){1,3}[.。。・・︒។։။۔።।][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}([\u0020\u00A0][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]+){1,3}[.。。・・︒។։။۔።।][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}([\u0020\u00A0][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]+){1,3})$"#,
282        ).unwrap();
283        pattern.is_match(&input.into())
284    }
285
286    pub fn find_possible_3wa(&self, input: impl Into<String>) -> Vec<String> {
287        let pattern = Regex::new(
288            r#"[^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}[.。。・・︒។։။۔።।][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}[.。。・・︒។։။۔።।][^0-9`~!@#$%^&*()+\-_=\[\{\]}\\|'<>.,?/;:£§º©®\s]{1,}"#,
289        ).unwrap();
290        pattern
291            .find_iter(&input.into())
292            .map(|matched| matched.as_str().to_string())
293            .collect()
294    }
295
296    #[cfg(feature = "sync")]
297    fn request<T: DeserializeOwned>(
298        &self,
299        url: String,
300        params: Option<HashMap<&str, String>>,
301    ) -> Result<T> {
302        let response = Client::new()
303            .get(&url)
304            .query(&params)
305            .headers(self.headers.clone())
306            .header(W3W_WRAPPER, &self.user_agent)
307            .header(HEADER_WHAT3WORDS_API_KEY, &self.api_key)
308            .send()
309            .map_err(Error::from)?;
310
311        if !response.status().is_success() {
312            let error_response = response.json::<ErrorResult>().map_err(Error::from)?;
313            return Err(Error::Api(
314                error_response.error.code,
315                error_response.error.message,
316            ));
317        }
318        match response.content_length() {
319            // Captures successful responses with no content
320            Some(0) => Ok(serde_json::from_str("null").unwrap()),
321            _ => response.json::<T>().map_err(Error::from),
322        }
323    }
324
325    #[cfg(not(feature = "sync"))]
326    async fn request<T: DeserializeOwned>(
327        &self,
328        url: String,
329        params: Option<HashMap<&str, String>>,
330    ) -> Result<T> {
331        let response = Client::new()
332            .get(&url)
333            .query(&params)
334            .headers(self.headers.clone())
335            .header(W3W_WRAPPER, &self.user_agent)
336            .header(HEADER_WHAT3WORDS_API_KEY, &self.api_key)
337            .send()
338            .await
339            .map_err(Error::from)?;
340
341        if !response.status().is_success() {
342            let error_response = response.json::<ErrorResult>().await.map_err(Error::from)?;
343            return Err(Error::Api(
344                error_response.error.code,
345                error_response.error.message,
346            ));
347        }
348        match response.content_length() {
349            // Captures successful responses with no content
350            Some(0) => Ok(serde_json::from_str("null").unwrap()),
351            _ => response.json::<T>().await.map_err(Error::from),
352        }
353    }
354}
355
356#[cfg(test)]
357#[cfg(feature = "sync")]
358mod sync_tests {
359    use super::*;
360    use crate::{
361        models::{
362            autosuggest::Autosuggest,
363            location::{ConvertTo3wa, ConvertToCoordinates},
364        },
365        Address, AddressGeoJson, GridSection, Suggestion,
366    };
367
368    use mockito::{Matcher, Server};
369    use serde_json::json;
370
371    #[test]
372    fn test_custom_headers() {
373        let w3w = What3words::new("TEST_API_KEY").header("Custom-Header", "CustomValue");
374
375        assert_eq!(
376            w3w.headers.get("Custom-Header"),
377            Some(&HeaderValue::from_static("CustomValue"))
378        );
379    }
380
381    #[test]
382    fn test_custom_hostname() {
383        let w3w = What3words::new("TEST_API_KEY").hostname("https://custom.api.url");
384        assert_eq!(w3w.host, "https://custom.api.url");
385    }
386
387    #[test]
388    fn test_error_display() {
389        let network_error = Error::Network(String::from("Connection lost"));
390        assert_eq!(
391            format!("{}", network_error),
392            "Network error: Connection lost"
393        );
394
395        let http_error = Error::Http(String::from("404 Not Found"));
396        assert_eq!(format!("{}", http_error), "HTTP error: 404 Not Found");
397
398        let error_result = ErrorResult {
399            error: crate::models::error::Error {
400                code: String::from("400"),
401                message: String::from("Bad Request"),
402            },
403        };
404        let api_error = Error::Api(error_result.error.code, error_result.error.message);
405        assert_eq!(format!("{}", api_error), "W3W error: 400 Bad Request");
406
407        let decode_error = Error::Decode(String::from("Invalid JSON"));
408        assert_eq!(format!("{}", decode_error), "Decode error: Invalid JSON");
409
410        let unknown_error = Error::Unknown(String::from("Something went wrong"));
411        assert_eq!(
412            format!("{}", unknown_error),
413            "Unknown error: Something went wrong"
414        );
415    }
416
417    #[test]
418    fn test_convert_to_3wa() {
419        let words = "filled.count.soap";
420        let mut mock_server = Server::new();
421        let url = mock_server.url();
422        let mock = mock_server
423            .mock("GET", "/convert-to-3wa")
424            .match_query(mockito::Matcher::AllOf(vec![
425                Matcher::UrlEncoded("coordinates".into(), "51.521251,-0.203586".into()),
426                Matcher::UrlEncoded("format".into(), "json".into()),
427            ]))
428            .with_status(200)
429            .with_body(
430                json!({
431                    "country": "GB",
432                    "square": {
433                        "southwest": {
434                            "lng": -0.203607,
435                            "lat": 51.521241
436                        },
437                        "northeast": {
438                            "lng": -0.203575,
439                            "lat": 51.521261
440                        }
441                    },
442                    "nearestPlace": "Bayswater, London",
443                    "coordinates": {
444                        "lng": -0.203586,
445                        "lat": 51.521251
446                    },
447                    "words": words,
448                    "language": "en",
449                    "map": format!("https://w3w.co/{}", words)
450                })
451                .to_string(),
452            )
453            .create();
454
455        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
456        let result: Address = w3w
457            .convert_to_3wa(&ConvertTo3wa::new(51.521251, -0.203586))
458            .unwrap();
459        mock.assert();
460        assert_eq!(result.words, words);
461    }
462
463    #[test]
464    fn test_convert_to_coordinates() {
465        let words = "filled.count.soap";
466        let mut mock_server = Server::new();
467        let url = mock_server.url();
468        let mock = mock_server
469            .mock("GET", "/convert-to-coordinates")
470            .match_query(Matcher::AllOf(vec![
471                Matcher::UrlEncoded("words".into(), words.into()),
472                Matcher::UrlEncoded("format".into(), "json".into()),
473            ]))
474            .with_status(200)
475            .with_body(
476                json!({
477                    "country": "GB",
478                    "square": {
479                        "southwest": {
480                            "lng": -0.203607,
481                            "lat": 51.521241
482                        },
483                        "northeast": {
484                            "lng": -0.203575,
485                            "lat": 51.521261
486                        }
487                    },
488                    "nearestPlace": "Bayswater, London",
489                    "coordinates": {
490                        "lng": -0.203586,
491                        "lat": 51.521251
492                    },
493                    "words": words,
494                    "language": "en",
495                    "map": format!("https://w3w.co/{}", words)
496                })
497                .to_string(),
498            )
499            .create();
500
501        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
502        let result: Address = w3w
503            .convert_to_coordinates(&ConvertToCoordinates::new(words))
504            .unwrap();
505        mock.assert();
506        assert_eq!(result.coordinates.lng, -0.203586);
507        assert_eq!(result.coordinates.lat, 51.521251);
508    }
509
510    #[test]
511    fn test_convert_to_coordinates_bad_words() {
512        let bad_words = "filled.count";
513        let mut mock_server = Server::new();
514        let url = mock_server.url();
515        let mock = mock_server
516            .mock("GET", "/convert-to-coordinates")
517            .match_query(Matcher::AllOf(vec![
518                Matcher::UrlEncoded("words".into(), bad_words.into()),
519                Matcher::UrlEncoded("format".into(), "json".into()),
520            ]))
521            .with_status(400)
522            .with_body(
523                json!({
524                    "error": {
525                        "code": "BadWords",
526                        "message": "words must be a valid 3 word address, such as filled.count.soap or ///filled.count.soap"
527                    }
528                })
529                .to_string(),
530            )
531            .create();
532
533        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
534        let result: std::result::Result<Address, Error> =
535            w3w.convert_to_coordinates::<Address>(&ConvertToCoordinates::new(bad_words));
536        mock.assert();
537        assert!(result.is_err());
538        let error = result.err().unwrap();
539        assert_eq!(format!("{}", error), "W3W error: BadWords words must be a valid 3 word address, such as filled.count.soap or ///filled.count.soap");
540    }
541
542    #[test]
543    fn test_convert_to_coordinates_with_locale() {
544        let words = "seruuhen.zemseg.dagaldah";
545        let mut mock_server = Server::new();
546        let url = mock_server.url();
547        let mock = mock_server
548            .mock("GET", "/convert-to-coordinates")
549            .match_query(Matcher::AllOf(vec![
550                Matcher::UrlEncoded("words".into(), words.into()),
551                Matcher::UrlEncoded("format".into(), "json".into()),
552                Matcher::UrlEncoded("locale".into(), "mn_la".into()),
553            ]))
554            .with_status(200)
555            .with_body(
556                json!({
557                    "country": "GB",
558                    "square": {
559                        "southwest": {
560                            "lng": -0.195543,
561                            "lat": 51.520833
562                        },
563                        "northeast": {
564                            "lng": -0.195499,
565                            "lat": 51.52086
566                        }
567                    },
568                    "nearestPlace": "Лондон",
569                    "coordinates": {
570                        "lng": -0.195521,
571                        "lat": 51.520847
572                    },
573                    "words": words,
574                    "language": "mn",
575                    "locale": "mn_la",
576                    "map": format!("https://w3w.co/{}", words),
577                })
578                .to_string(),
579            )
580            .create();
581
582        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
583        let result: Address = w3w
584            .convert_to_coordinates(&ConvertToCoordinates::new(words).locale("mn_la"))
585            .unwrap();
586        mock.assert();
587        assert_eq!(result.words, words);
588        assert_eq!(result.locale, Some("mn_la".to_string()));
589    }
590
591    #[test]
592    fn test_convert_to_coordinates_geojson() {
593        let words = "filled.count.soap";
594        let mut mock_server = Server::new();
595        let url = mock_server.url();
596        let mock = mock_server
597            .mock("GET", "/convert-to-coordinates")
598            .match_query(Matcher::AllOf(vec![
599                Matcher::UrlEncoded("words".into(), words.into()),
600                Matcher::UrlEncoded("format".into(), "geojson".into()),
601            ]))
602            .with_status(200)
603            .with_body(
604                json!({
605                    "features": [
606                        {
607                            "bbox": [
608                                -0.195543,
609                                51.520833,
610                                -0.195499,
611                                51.52086
612                            ],
613                            "geometry": {
614                                "coordinates": [
615                                    -0.195521,
616                                    51.520847
617                                ],
618                                "type": "Point"
619                            },
620                            "type": "Feature",
621                            "properties": {
622                                "country": "GB",
623                                "nearestPlace": "Bayswater, London",
624                                "words": words,
625                                "language": "en",
626                                "map": format!("https://w3w.co/{}", words)
627                            }
628                        }
629                    ],
630                    "type": "FeatureCollection"
631                })
632                .to_string(),
633            )
634            .create();
635
636        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
637        let result: AddressGeoJson = w3w
638            .convert_to_coordinates(&ConvertToCoordinates::new(words))
639            .unwrap();
640        mock.assert();
641        let bbox = result.features[0].bbox.as_ref().unwrap();
642        assert_eq!(bbox[0], -0.195543);
643        assert_eq!(bbox[1], 51.520833);
644        assert_eq!(bbox[2], -0.195499);
645        assert_eq!(bbox[3], 51.52086);
646    }
647
648    #[test]
649    fn test_available_languages() {
650        let mut mock_server = Server::new();
651        let url = mock_server.url();
652
653        let mock = mock_server
654            .mock("GET", "/available-languages")
655            .with_status(200)
656            .with_body(
657                json!({
658                    "languages": [
659                        {
660                            "nativeName": "English",
661                            "code": "en",
662                            "name": "English"
663                        },
664                        {
665                            "nativeName": "Français",
666                            "code": "fr",
667                            "name": "French"
668                        }
669                    ]
670                })
671                .to_string(),
672            )
673            .create();
674
675        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
676        let result = w3w.available_languages().unwrap();
677        mock.assert();
678        assert_eq!(result.languages.len(), 2);
679        assert_eq!(result.languages[0].code, "en");
680        assert_eq!(result.languages[1].code, "fr");
681    }
682
683    #[test]
684    fn test_grid_section() {
685        let mut mock_server = Server::new();
686        let url = mock_server.url();
687        let mock = mock_server
688            .mock("GET", "/grid-section")
689            .match_query(Matcher::AllOf(vec![
690                Matcher::UrlEncoded(
691                    "bounding-box".into(),
692                    "52.207988,0.116126,52.208867,0.11754".into(),
693                ),
694                Matcher::UrlEncoded("format".into(), "json".into()),
695            ]))
696            .with_status(200)
697            .with_body(
698                json!({
699                    "lines": [
700                        {
701                            "start": {
702                                "lng": 0.116126,
703                                "lat": 52.207988
704                            },
705                            "end": {
706                                "lng": 0.11754,
707                                "lat": 52.208867
708                            }
709                        }
710                    ]
711                })
712                .to_string(),
713            )
714            .create();
715
716        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
717        let result: GridSection = w3w
718            .grid_section(&BoundingBox::new(52.207988, 0.116126, 52.208867, 0.11754))
719            .unwrap();
720        mock.assert();
721        assert_eq!(result.lines.len(), 1);
722    }
723
724    #[test]
725    fn test_autosuggest() {
726        let mut mock_server = Server::new();
727        let url = mock_server.url();
728        let mock = mock_server
729            .mock("GET", "/autosuggest")
730            .match_query(Matcher::AllOf(vec![Matcher::UrlEncoded(
731                "input".into(),
732                "filled.count.soap".into(),
733            )]))
734            .with_status(200)
735            .with_body(
736                json!({
737                    "suggestions": [
738                        {
739                            "country": "GB",
740                            "nearestPlace": "Bayswater, London",
741                            "words": "filled.count.soap",
742                            "rank": 1,
743                            "language": "en"
744                        }
745                    ]
746                })
747                .to_string(),
748            )
749            .create();
750
751        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
752        let result = w3w
753            .autosuggest(&Autosuggest::new("filled.count.soap"))
754            .unwrap();
755        mock.assert();
756        assert_eq!(result.suggestions.len(), 1);
757        assert_eq!(result.suggestions[0].words, "filled.count.soap");
758    }
759
760    #[test]
761    fn test_autosuggest_with_coordinates() {
762        let mut mock_server = Server::new();
763        let url = mock_server.url();
764        let mock = mock_server
765            .mock("GET", "/autosuggest-with-coordinates")
766            .match_query(Matcher::AllOf(vec![Matcher::UrlEncoded(
767                "input".into(),
768                "filled.count.soap".into(),
769            )]))
770            .with_status(200)
771            .with_body(
772                json!({
773                    "suggestions": [
774                        {
775                            "country": "GB",
776                            "nearestPlace": "Bayswater, London",
777                            "words": "filled.count.soap",
778                            "rank": 1,
779                            "language": "en"
780                        }
781                    ]
782                })
783                .to_string(),
784            )
785            .create();
786
787        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
788        let result = w3w
789            .autosuggest_with_coordinates(&Autosuggest::new("filled.count.soap"))
790            .unwrap();
791
792        mock.assert();
793        assert_eq!(result.suggestions.len(), 1);
794        assert_eq!(result.suggestions[0].words, "filled.count.soap");
795    }
796
797    #[test]
798    fn test_autosuggest_selection() {
799        let mut mock_server = Server::new();
800        let url = mock_server.url();
801        let mock = mock_server
802            .mock("GET", "/autosuggest-selection")
803            .match_query(Matcher::AllOf(vec![
804                Matcher::UrlEncoded("selection".into(), "filled.count.soap".into()),
805                Matcher::UrlEncoded("rank".into(), "1".into()),
806                Matcher::UrlEncoded("raw-input".into(), "i.h.r".into()),
807            ]))
808            .with_status(200)
809            .create();
810
811        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
812        let suggestion = Suggestion {
813            words: "filled.count.soap".to_string(),
814            country: "GB".to_string(),
815            nearest_place: "Bayswater, London".to_string(),
816            distance_to_focus_km: None,
817            rank: 1,
818            square: None,
819            coordinates: None,
820            language: "en".to_string(),
821            map: None,
822        };
823        let result = w3w.autosuggest_selection(&AutosuggestSelection::new("i.h.r", &suggestion));
824        mock.assert();
825        assert!(result.is_ok());
826    }
827
828    #[test]
829    fn test_is_valid_3wa_true() {
830        let words = "filled.count.soap";
831        let mut mock_server = Server::new();
832        let url = mock_server.url();
833
834        let mock = mock_server
835            .mock("GET", "/autosuggest")
836            .match_query(Matcher::AllOf(vec![
837                Matcher::UrlEncoded("input".into(), words.into()),
838                Matcher::UrlEncoded("n-results".into(), "1".into()),
839            ]))
840            .with_status(200)
841            .with_body(
842                json!({
843                    "suggestions": [
844                        {
845                            "country": "GB",
846                            "nearestPlace": "Bayswater, London",
847                            "words": "filled.count.soap",
848                            "rank": 1,
849                            "language": "en"
850                        }
851                    ]
852                })
853                .to_string(),
854            )
855            .create();
856
857        let w3w: What3words = What3words::new("TEST_API_KEY").hostname(&url);
858        assert!(w3w.is_valid_3wa(words));
859        mock.assert();
860    }
861
862    #[test]
863    fn test_is_valid_3wa_false() {
864        let words = "filled.count";
865        let w3w: What3words = What3words::new("TEST_API_KEY");
866        assert!(!w3w.is_valid_3wa(words));
867    }
868
869    #[test]
870    fn test_is_valid_3wa_false_doesnt_match() {
871        let words = "rust.is.cool";
872        let mut mock_server = Server::new();
873        let url = mock_server.url();
874
875        let mock = mock_server
876            .mock("GET", "/autosuggest")
877            .match_query(Matcher::AllOf(vec![
878                Matcher::UrlEncoded("input".into(), words.into()),
879                Matcher::UrlEncoded("n-results".into(), "1".into()),
880            ]))
881            .with_status(200)
882            .with_body(
883                json!({
884                    "suggestions": [
885                        {
886                            "country": "US",
887                            "nearestPlace": "Huntington Station, New York",
888                            "words": "rust.this.cool",
889                            "rank": 1,
890                            "language": "en"
891                        }
892                    ]
893                })
894                .to_string(),
895            )
896            .create();
897
898        let w3w: What3words = What3words::new("TEST_API_KEY").hostname(&url);
899        assert!(!w3w.is_valid_3wa(words));
900        mock.assert();
901    }
902
903    #[test]
904    fn test_did_you_mean_true() {
905        let w3w = What3words::new("TEST_API_KEY");
906        assert!(w3w.did_you_mean("filled。count。soap"));
907        assert!(w3w.did_you_mean("filled count soap"));
908    }
909
910    #[test]
911    fn test_did_you_mean_false() {
912        let w3w = What3words::new("TEST_API_KEY");
913        assert!(!w3w.did_you_mean("filledcountsoap"));
914    }
915
916    #[test]
917    fn test_is_possible_3wa_true() {
918        let w3w = What3words::new("TEST_API_KEY");
919        assert!(w3w.is_possible_3wa("filled.count.soap"));
920    }
921
922    #[test]
923    fn test_is_possible_3wa_false() {
924        let w3w = What3words::new("TEST_API_KEY");
925        assert!(!w3w.is_possible_3wa("filled count soap"));
926    }
927
928    #[test]
929    fn test_find_possible_3wa_true() {
930        let w3w = What3words::new("TEST_API_KEY");
931        let result = w3w.find_possible_3wa("This is a test with filled.count.soap in it.");
932        assert_eq!(result.len(), 1);
933        assert_eq!(result[0], "filled.count.soap");
934    }
935
936    #[test]
937    fn test_find_possible_3wa_false() {
938        let w3w = What3words::new("TEST_API_KEY");
939        let result = w3w.find_possible_3wa("This is a test with filled count soap in it.");
940        assert_eq!(result.len(), 0);
941    }
942}
943
944#[cfg(test)]
945#[cfg(not(feature = "sync"))]
946mod async_tests {
947    use super::*;
948    use crate::{
949        models::{
950            autosuggest::Autosuggest,
951            location::{ConvertTo3wa, ConvertToCoordinates},
952        },
953        Address, AddressGeoJson, GridSection, Suggestion,
954    };
955    use mockito::{Matcher, Server};
956    use serde_json::json;
957
958    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
959    async fn test_convert_to_3wa() {
960        let words = "filled.count.soap";
961        let mut mock_server = Server::new_async().await;
962        let url = mock_server.url();
963        let mock = mock_server
964            .mock("GET", "/convert-to-3wa")
965            .match_query(mockito::Matcher::AllOf(vec![
966                Matcher::UrlEncoded("coordinates".into(), "51.521251,-0.203586".into()),
967                Matcher::UrlEncoded("format".into(), "json".into()),
968            ]))
969            .with_status(200)
970            .with_body(
971                json!({
972                    "country": "GB",
973                    "square": {
974                        "southwest": {
975                            "lng": -0.203607,
976                            "lat": 51.521241
977                        },
978                        "northeast": {
979                            "lng": -0.203575,
980                            "lat": 51.521261
981                        }
982                    },
983                    "nearestPlace": "Bayswater, London",
984                    "coordinates": {
985                        "lng": -0.203586,
986                        "lat": 51.521251
987                    },
988                    "words": words,
989                    "language": "en",
990                    "map": format!("https://w3w.co/{}", words)
991                })
992                .to_string(),
993            )
994            .create();
995
996        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
997        let result: Address = w3w
998            .convert_to_3wa(&ConvertTo3wa::new(51.521251, -0.203586))
999            .await
1000            .unwrap();
1001        mock.assert_async().await;
1002        assert_eq!(result.words, "filled.count.soap");
1003    }
1004
1005    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1006    async fn test_convert_to_coordinates() {
1007        let words = "filled.count.soap";
1008        let mut mock_server = Server::new_async().await;
1009        let url = mock_server.url();
1010        let mock = mock_server
1011            .mock("GET", "/convert-to-coordinates")
1012            .match_query(Matcher::AllOf(vec![
1013                Matcher::UrlEncoded("words".into(), words.into()),
1014                Matcher::UrlEncoded("format".into(), "json".into()),
1015            ]))
1016            .with_status(200)
1017            .with_body(
1018                json!({
1019                    "country": "GB",
1020                    "square": {
1021                        "southwest": {
1022                            "lng": -0.203607,
1023                            "lat": 51.521241
1024                        },
1025                        "northeast": {
1026                            "lng": -0.203575,
1027                            "lat": 51.521261
1028                        }
1029                    },
1030                    "nearestPlace": "Bayswater, London",
1031                    "coordinates": {
1032                        "lng": -0.203586,
1033                        "lat": 51.521251
1034                    },
1035                    "words": words,
1036                    "language": "en",
1037                    "map": format!("https://w3w.co/{}", words)
1038                })
1039                .to_string(),
1040            )
1041            .create();
1042
1043        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
1044        let result: Address = w3w
1045            .convert_to_coordinates(&ConvertToCoordinates::new(words))
1046            .await
1047            .unwrap();
1048        mock.assert_async().await;
1049        assert_eq!(result.coordinates.lng, -0.203586);
1050        assert_eq!(result.coordinates.lat, 51.521251);
1051    }
1052
1053    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1054    async fn test_convert_to_coordinates_bad_words() {
1055        let bad_words = "filled.count";
1056        let mut mock_server = Server::new_async().await;
1057        let url = mock_server.url();
1058        let mock = mock_server
1059            .mock("GET", "/convert-to-coordinates")
1060            .match_query(Matcher::AllOf(vec![
1061                Matcher::UrlEncoded("words".into(), bad_words.into()),
1062                Matcher::UrlEncoded("format".into(), "json".into()),
1063            ]))
1064            .with_status(400)
1065            .with_body(
1066                json!({
1067                    "error": {
1068                        "code": "BadWords",
1069                        "message": "words must be a valid 3 word address, such as filled.count.soap or ///filled.count.soap"
1070                    }
1071                })
1072                .to_string(),
1073            )
1074            .create();
1075
1076        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
1077        let result: std::result::Result<Address, Error> = w3w
1078            .convert_to_coordinates::<Address>(&ConvertToCoordinates::new(bad_words))
1079            .await;
1080        mock.assert_async().await;
1081        assert!(result.is_err());
1082        let error = result.err().unwrap();
1083        assert_eq!(format!("{}", error), "W3W error: BadWords words must be a valid 3 word address, such as filled.count.soap or ///filled.count.soap");
1084    }
1085
1086    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1087    async fn test_convert_to_coordinates_geojson() {
1088        let mut mock_server = Server::new_async().await;
1089        let url = mock_server.url();
1090        let mock = mock_server
1091            .mock("GET", "/convert-to-coordinates")
1092            .match_query(Matcher::AllOf(vec![
1093                Matcher::UrlEncoded("words".into(), "filled.count.soap".into()),
1094                Matcher::UrlEncoded("format".into(), "geojson".into()),
1095            ]))
1096            .with_status(200)
1097            .with_body(
1098                json!({
1099                    "features": [
1100                        {
1101                            "bbox": [
1102                                -0.195543,
1103                                51.520833,
1104                                -0.195499,
1105                                51.52086
1106                            ],
1107                            "geometry": {
1108                                "coordinates": [
1109                                    -0.195521,
1110                                    51.520847
1111                                ],
1112                                "type": "Point"
1113                            },
1114                            "type": "Feature",
1115                            "properties": {
1116                                "country": "GB",
1117                                "nearestPlace": "Bayswater, London",
1118                                "words": "filled.count.soap",
1119                                "language": "en",
1120                                "map": "https://w3w.co/filled.count.soap"
1121                            }
1122                        }
1123                    ],
1124                    "type": "FeatureCollection"
1125                })
1126                .to_string(),
1127            )
1128            .create();
1129
1130        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
1131        let result: AddressGeoJson = w3w
1132            .convert_to_coordinates(&ConvertToCoordinates::new("filled.count.soap"))
1133            .await
1134            .unwrap();
1135        mock.assert_async().await;
1136        let bbox = result.features[0].bbox.as_ref().unwrap();
1137        assert_eq!(bbox[0], -0.195543);
1138        assert_eq!(bbox[1], 51.520833);
1139        assert_eq!(bbox[2], -0.195499);
1140        assert_eq!(bbox[3], 51.52086);
1141    }
1142
1143    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1144    async fn test_available_languages() {
1145        let mut mock_server = Server::new_async().await;
1146        let url = mock_server.url();
1147
1148        let mock = mock_server
1149            .mock("GET", "/available-languages")
1150            .with_status(200)
1151            .with_body(
1152                json!({
1153                    "languages": [
1154                        {
1155                            "nativeName": "English",
1156                            "code": "en",
1157                            "name": "English"
1158                        },
1159                        {
1160                            "nativeName": "Français",
1161                            "code": "fr",
1162                            "name": "French"
1163                        }
1164                    ]
1165                })
1166                .to_string(),
1167            )
1168            .create();
1169
1170        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
1171        let result = w3w.available_languages().await.unwrap();
1172        mock.assert_async().await;
1173        assert_eq!(result.languages.len(), 2);
1174        assert_eq!(result.languages[0].code, "en");
1175        assert_eq!(result.languages[1].code, "fr");
1176    }
1177
1178    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1179    async fn test_grid_section() {
1180        let mut mock_server = Server::new_async().await;
1181        let url = mock_server.url();
1182        let mock = mock_server
1183            .mock("GET", "/grid-section")
1184            .match_query(Matcher::AllOf(vec![
1185                Matcher::UrlEncoded(
1186                    "bounding-box".into(),
1187                    "52.207988,0.116126,52.208867,0.11754".into(),
1188                ),
1189                Matcher::UrlEncoded("format".into(), "json".into()),
1190            ]))
1191            .with_status(200)
1192            .with_body(
1193                json!({
1194                    "lines": [
1195                        {
1196                            "start": {
1197                                "lng": 0.116126,
1198                                "lat": 52.207988
1199                            },
1200                            "end": {
1201                                "lng": 0.11754,
1202                                "lat": 52.208867
1203                            }
1204                        }
1205                    ]
1206                })
1207                .to_string(),
1208            )
1209            .create();
1210
1211        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
1212        let result: GridSection = w3w
1213            .grid_section(&BoundingBox::new(52.207988, 0.116126, 52.208867, 0.11754))
1214            .await
1215            .unwrap();
1216        mock.assert_async().await;
1217        assert_eq!(result.lines.len(), 1);
1218    }
1219
1220    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1221    async fn test_autosuggest() {
1222        let mut mock_server = Server::new_async().await;
1223        let url = mock_server.url();
1224        let mock = mock_server
1225            .mock("GET", "/autosuggest")
1226            .match_query(Matcher::AllOf(vec![Matcher::UrlEncoded(
1227                "input".into(),
1228                "filled.count.soap".into(),
1229            )]))
1230            .with_status(200)
1231            .with_body(
1232                json!({
1233                    "suggestions": [
1234                        {
1235                            "country": "GB",
1236                            "nearestPlace": "Bayswater, London",
1237                            "words": "filled.count.soap",
1238                            "rank": 1,
1239                            "language": "en"
1240                        }
1241                    ]
1242                })
1243                .to_string(),
1244            )
1245            .create();
1246
1247        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
1248        let result = w3w
1249            .autosuggest(&Autosuggest::new("filled.count.soap"))
1250            .await
1251            .unwrap();
1252        mock.assert_async().await;
1253        assert_eq!(result.suggestions.len(), 1);
1254        assert_eq!(result.suggestions[0].words, "filled.count.soap");
1255    }
1256
1257    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1258    async fn test_autosuggest_with_coordinates() {
1259        let mut mock_server = Server::new_async().await;
1260        let url = mock_server.url();
1261        let mock = mock_server
1262            .mock("GET", "/autosuggest-with-coordinates")
1263            .match_query(Matcher::AllOf(vec![Matcher::UrlEncoded(
1264                "input".into(),
1265                "filled.count.soap".into(),
1266            )]))
1267            .with_status(200)
1268            .with_body(
1269                json!({
1270                    "suggestions": [
1271                        {
1272                            "country": "GB",
1273                            "nearestPlace": "Bayswater, London",
1274                            "words": "filled.count.soap",
1275                            "rank": 1,
1276                            "language": "en"
1277                        }
1278                    ]
1279                })
1280                .to_string(),
1281            )
1282            .create();
1283
1284        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
1285        let result = w3w
1286            .autosuggest_with_coordinates(&Autosuggest::new("filled.count.soap"))
1287            .await
1288            .unwrap();
1289
1290        mock.assert_async().await;
1291        assert_eq!(result.suggestions.len(), 1);
1292        assert_eq!(result.suggestions[0].words, "filled.count.soap");
1293    }
1294
1295    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1296    async fn test_autosuggest_selection() {
1297        let mut mock_server = Server::new_async().await;
1298        let url = mock_server.url();
1299        let mock = mock_server
1300            .mock("GET", "/autosuggest-selection")
1301            .match_query(Matcher::AllOf(vec![
1302                Matcher::UrlEncoded("selection".into(), "filled.count.soap".into()),
1303                Matcher::UrlEncoded("rank".into(), "1".into()),
1304                Matcher::UrlEncoded("raw-input".into(), "i.h.r".into()),
1305            ]))
1306            .with_status(200)
1307            .create();
1308
1309        let w3w = What3words::new("TEST_API_KEY").hostname(&url);
1310        let suggestion = Suggestion {
1311            words: "filled.count.soap".to_string(),
1312            country: "GB".to_string(),
1313            nearest_place: "Bayswater, London".to_string(),
1314            distance_to_focus_km: None,
1315            rank: 1,
1316            square: None,
1317            coordinates: None,
1318            language: "en".to_string(),
1319            map: None,
1320        };
1321        let result = w3w
1322            .autosuggest_selection(&AutosuggestSelection::new("i.h.r", &suggestion))
1323            .await;
1324        mock.assert_async().await;
1325        assert!(result.is_ok());
1326    }
1327
1328    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1329    async fn test_is_valid_3wa_true() {
1330        let words = "filled.count.soap";
1331        let mut mock_server = Server::new_async().await;
1332        let url = mock_server.url();
1333
1334        let mock = mock_server
1335            .mock("GET", "/autosuggest")
1336            .match_query(Matcher::AllOf(vec![
1337                Matcher::UrlEncoded("input".into(), words.into()),
1338                Matcher::UrlEncoded("n-results".into(), "1".into()),
1339            ]))
1340            .with_status(200)
1341            .with_body(
1342                json!({
1343                    "suggestions": [
1344                        {
1345                            "country": "GB",
1346                            "nearestPlace": "Bayswater, London",
1347                            "words": "filled.count.soap",
1348                            "rank": 1,
1349                            "language": "en"
1350                        }
1351                    ]
1352                })
1353                .to_string(),
1354            )
1355            .create();
1356
1357        let w3w: What3words = What3words::new("TEST_API_KEY").hostname(&url);
1358        assert!(w3w.is_valid_3wa(words).await);
1359        mock.assert_async().await;
1360    }
1361
1362    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1363    async fn test_is_valid_3wa_false() {
1364        let words = "filled.count";
1365        let w3w: What3words = What3words::new("TEST_API_KEY");
1366        assert!(!w3w.is_valid_3wa(words).await);
1367    }
1368
1369    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1370    async fn test_is_valid_3wa_false_doesnt_match() {
1371        let words = "rust.is.cool";
1372        let mut mock_server = Server::new_async().await;
1373        let url = mock_server.url();
1374
1375        let mock = mock_server
1376            .mock("GET", "/autosuggest")
1377            .match_query(Matcher::AllOf(vec![
1378                Matcher::UrlEncoded("input".into(), words.into()),
1379                Matcher::UrlEncoded("n-results".into(), "1".into()),
1380            ]))
1381            .with_status(200)
1382            .with_body(
1383                json!({
1384                    "suggestions": [
1385                        {
1386                            "country": "US",
1387                            "nearestPlace": "Huntington Station, New York",
1388                            "words": "rust.this.cool",
1389                            "rank": 1,
1390                            "language": "en"
1391                        }
1392                    ]
1393                })
1394                .to_string(),
1395            )
1396            .create();
1397
1398        let w3w: What3words = What3words::new("TEST_API_KEY").hostname(&url);
1399        assert!(!w3w.is_valid_3wa(words).await);
1400        mock.assert();
1401    }
1402}