egg_mode/place/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5//! Types and methods for looking up locations.
6//!
7//! Location search for Twitter works in one of two ways. The most direct method is to take a
8//! latitude/longitude coordinate (say, from a devide's GPS system or by geolocating from wi-fi
9//! networks, or simply from a known coordinate) and call `reverse_geocode`. Twitter says
10//! `reverse_geocode` provides more of a "raw data access", and it can be considered to merely show
11//! what locations are in that point or area.
12//!
13//! On the other hand, if you're intending to let a user select from a list of locations, you can
14//! use the `search_*` methods instead. These have much of the same available parameters, but will
15//! "potentially re-order \[results\] with regards to the user who is authenticated." In addition,
16//! the results may potentially pull in "nearby" results to allow for a more broad selection or to
17//! account for inaccurate location reporting.
18//!
19//! Since there are several optional parameters to both query methods, each one is assembled as a
20//! builder. You can create the builder with the `reverse_geocode`, `search_point`, `search_query`,
21//! or `search_ip` functions. From there, add any additional parameters by chaining method calls
22//! onto the builder. When you're ready to peform the search call, hand your tokens to `call`, and
23//! the list of results will be returned.
24//!
25//! Along with the list of place results, Twitter also returns the full search URL. egg-mode
26//! returns this URL as part of the result struct, allowing you to perform the same search using
27//! the `reverse_geocode_url` or `search_url` functions.
28
29use std::collections::HashMap;
30use std::fmt;
31
32use serde::de::Error;
33use serde::{Deserialize, Deserializer, Serialize};
34use serde_json;
35
36use crate::common::*;
37use crate::{auth, error, links};
38
39mod fun;
40
41pub use self::fun::*;
42
43// https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/geo-objects#place
44///Represents a named location.
45#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct Place {
47    ///Alphanumeric ID of the location.
48    pub id: String,
49    ///Map of miscellaneous information about this place. See [Twitter's documentation][attrib] for
50    ///details and common attribute keys.
51    ///
52    ///[attrib]: https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/geo-objects#place
53    pub attributes: HashMap<String, String>,
54    ///A bounding box of latitude/longitude coordinates that encloses this place.
55    #[serde(with = "serde_bounding_box")]
56    pub bounding_box: Vec<(f64, f64)>,
57    ///Name of the country containing this place.
58    pub country: String,
59    ///Shortened country code representing the country containing this place.
60    pub country_code: String,
61    ///Full human-readable name of this place.
62    pub full_name: String,
63    ///Short human-readable name of this place.
64    pub name: String,
65    ///The type of location represented by this place.
66    pub place_type: PlaceType,
67    ///If present, the country or administrative region that contains this place.
68    pub contained_within: Option<Vec<Place>>,
69}
70
71///Represents the type of region represented by a given place.
72#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
73pub enum PlaceType {
74    ///A coordinate with no area.
75    #[serde(rename = "poi")]
76    PointOfInterest,
77    ///A region within a city.
78    #[serde(rename = "neighborhood")]
79    Neighborhood,
80    ///An entire city.
81    #[serde(rename = "city")]
82    City,
83    ///An administrative area, e.g. state or province.
84    #[serde(rename = "admin")]
85    Admin,
86    ///An entire country.
87    #[serde(rename = "country")]
88    Country,
89}
90
91///Represents the accuracy of a GPS measurement, when being given to a location search.
92#[derive(Debug, Copy, Clone)]
93pub enum Accuracy {
94    ///Location accurate to the given number of meters.
95    Meters(f64),
96    ///Location accurate to the given number of feet.
97    Feet(f64),
98}
99
100///Represents the result of a location search, either via `reverse_geocode` or `search`.
101pub struct SearchResult {
102    ///The full URL used to pull the result list. This can be fed to the `_url` version of your
103    ///original call to avoid having to fill out the argument list again.
104    pub url: String,
105    ///The list of results from the search.
106    pub results: Vec<Place>,
107}
108
109impl<'de> Deserialize<'de> for SearchResult {
110    fn deserialize<D>(deser: D) -> Result<SearchResult, D::Error>
111    where
112        D: Deserializer<'de>,
113    {
114        let raw: serde_json::Value = serde_json::Value::deserialize(deser)?;
115        let url = raw
116            .get("query")
117            .and_then(|obj| obj.get("url"))
118            .ok_or_else(|| D::Error::custom("Malformed search result"))?
119            .to_string();
120        let results = raw
121            .get("result")
122            .and_then(|obj| obj.get("places"))
123            .and_then(|arr| <Vec<Place>>::deserialize(arr).ok())
124            .ok_or_else(|| D::Error::custom("Malformed search result"))?;
125        Ok(SearchResult { url, results })
126    }
127}
128
129///Represents a `reverse_geocode` query before it is sent.
130///
131///The available methods on this builder struct allow you to specify optional parameters to the
132///search operation. Where applicable, each method lists its default value and acceptable ranges.
133///
134///To complete your search setup and send the query to Twitter, hand your tokens to `call`. The
135///list of results from Twitter will be returned, as well as a URL to perform the same search via
136///`reverse_geocode_url`.
137pub struct GeocodeBuilder {
138    coordinate: (f64, f64),
139    accuracy: Option<Accuracy>,
140    granularity: Option<PlaceType>,
141    max_results: Option<u32>,
142}
143
144impl GeocodeBuilder {
145    ///Begins building a reverse-geocode query with the given coordinate.
146    fn new(latitude: f64, longitude: f64) -> Self {
147        GeocodeBuilder {
148            coordinate: (latitude, longitude),
149            accuracy: None,
150            granularity: None,
151            max_results: None,
152        }
153    }
154
155    ///Expands the area to search to the given radius. By default, this is zero.
156    ///
157    ///From Twitter: "If coming from a device, in practice, this value is whatever accuracy the
158    ///device has measuring its location (whether it be coming from a GPS, WiFi triangulation,
159    ///etc.)."
160    pub fn accuracy(self, accuracy: Accuracy) -> Self {
161        GeocodeBuilder {
162            accuracy: Some(accuracy),
163            ..self
164        }
165    }
166
167    ///Sets the minimal specificity of what kind of results to return. For example, passing `City`
168    ///to this will make the eventual result exclude neighborhoods and points.
169    pub fn granularity(self, granularity: PlaceType) -> Self {
170        GeocodeBuilder {
171            granularity: Some(granularity),
172            ..self
173        }
174    }
175
176    ///Restricts the maximum number of results returned in this search. This is not a guarantee
177    ///that the search will return this many results, but instead provides a hint as to how many
178    ///"nearby" results to return.
179    ///
180    ///This value has a default value of 20, which is also its maximum. If zero or a number greater
181    ///than 20 is passed here, it will be defaulted to 20 before sending to Twitter.
182    ///
183    ///From Twitter: "Ideally, only pass in the number of places you intend to display to the user
184    ///here."
185    pub fn max_results(self, max_results: u32) -> Self {
186        GeocodeBuilder {
187            max_results: Some(max_results),
188            ..self
189        }
190    }
191
192    ///Finalize the search parameters and return the results collection.
193    pub async fn call(&self, token: &auth::Token) -> Result<Response<SearchResult>, error::Error> {
194        let params = ParamList::new()
195            .add_param("lat", self.coordinate.0.to_string())
196            .add_param("long", self.coordinate.1.to_string())
197            .add_opt_param("accuracy", self.accuracy.map_string())
198            .add_opt_param("granularity", self.granularity.map_string())
199            .add_opt_param(
200                "max_results",
201                self.max_results.map(|count| {
202                    let count = if count == 0 || count > 20 { 20 } else { count };
203                    count.to_string()
204                }),
205            );
206
207        let req = get(links::place::REVERSE_GEOCODE, token, Some(&params));
208        request_with_json_response(req).await
209    }
210}
211
212enum PlaceQuery {
213    LatLon(f64, f64),
214    Query(CowStr),
215    IPAddress(CowStr),
216}
217
218///Represents a location search query before it is sent.
219///
220///The available methods on this builder struct allow you to specify optional parameters to the
221///search operation. Where applicable, each method lists its default value and acceptable ranges.
222///
223///To complete your search setup and send the query to Twitter, hand your tokens to `call`. The
224///list of results from Twitter will be returned, as well as a URL to perform the same search via
225///`search_url`.
226pub struct SearchBuilder {
227    query: PlaceQuery,
228    accuracy: Option<Accuracy>,
229    granularity: Option<PlaceType>,
230    max_results: Option<u32>,
231    contained_within: Option<String>,
232    attributes: Option<HashMap<String, String>>,
233}
234
235impl SearchBuilder {
236    ///Begins building a location search with the given query.
237    fn new(query: PlaceQuery) -> Self {
238        SearchBuilder {
239            query,
240            accuracy: None,
241            granularity: None,
242            max_results: None,
243            contained_within: None,
244            attributes: None,
245        }
246    }
247
248    ///Expands the area to search to the given radius. By default, this is zero.
249    ///
250    ///From Twitter: "If coming from a device, in practice, this value is whatever accuracy the
251    ///device has measuring its location (whether it be coming from a GPS, WiFi triangulation,
252    ///etc.)."
253    pub fn accuracy(self, accuracy: Accuracy) -> Self {
254        SearchBuilder {
255            accuracy: Some(accuracy),
256            ..self
257        }
258    }
259
260    ///Sets the minimal specificity of what kind of results to return. For example, passing `City`
261    ///to this will make the eventual result exclude neighborhoods and points.
262    pub fn granularity(self, granularity: PlaceType) -> Self {
263        SearchBuilder {
264            granularity: Some(granularity),
265            ..self
266        }
267    }
268
269    ///Restricts the maximum number of results returned in this search. This is not a guarantee
270    ///that the search will return this many results, but instead provides a hint as to how many
271    ///"nearby" results to return.
272    ///
273    ///From experimentation, this value has a default of 20 and a maximum of 100. If fewer
274    ///locations match the search parameters, fewer places will be returned.
275    ///
276    ///From Twitter: "Ideally, only pass in the number of places you intend to display to the user
277    ///here."
278    pub fn max_results(self, max_results: u32) -> Self {
279        SearchBuilder {
280            max_results: Some(max_results),
281            ..self
282        }
283    }
284
285    ///Restricts results to those contained within the given Place ID.
286    pub fn contained_within(self, contained_id: String) -> Self {
287        SearchBuilder {
288            contained_within: Some(contained_id),
289            ..self
290        }
291    }
292
293    ///Restricts results to those with the given attribute. A list of common attributes are
294    ///available in [Twitter's documentation for Places][attrs]. Custom attributes are supported in
295    ///this search, if you know them. This function may be called multiple times with different
296    ///`attribute_key` values to combine attribute search parameters.
297    ///
298    ///[attrs]: https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/geo-objects#place
299    ///
300    ///For example, `.attribute("street_address", "123 Main St")` searches for places with the
301    ///given street address.
302    pub fn attribute(self, attribute_key: String, attribute_value: String) -> Self {
303        let mut attrs = self.attributes.unwrap_or_default();
304        attrs.insert(attribute_key, attribute_value);
305
306        SearchBuilder {
307            attributes: Some(attrs),
308            ..self
309        }
310    }
311
312    ///Finalize the search parameters and return the results collection.
313    pub async fn call(&self, token: &auth::Token) -> Result<Response<SearchResult>, error::Error> {
314        let mut params = match &self.query {
315            PlaceQuery::LatLon(lat, long) => ParamList::new()
316                .add_param("lat", lat.to_string())
317                .add_param("long", long.to_string()),
318            PlaceQuery::Query(text) => ParamList::new().add_param("query", text.to_string()),
319            PlaceQuery::IPAddress(text) => ParamList::new().add_param("ip", text.to_string()),
320        }
321        .add_opt_param("accuracy", self.accuracy.map_string())
322        .add_opt_param("granularity", self.granularity.map_string())
323        .add_opt_param("max_results", self.max_results.map_string())
324        .add_opt_param("contained_within", self.contained_within.map_string());
325
326        if let Some(ref attrs) = self.attributes {
327            for (k, v) in attrs {
328                params.add_param_ref(format!("attribute:{}", k), v.clone());
329            }
330        }
331
332        let req = get(links::place::SEARCH, token, Some(&params));
333        request_with_json_response(req).await
334    }
335}
336
337///Display impl to make `to_string()` format the enum for sending to Twitter. This is *mostly* just
338///a lowercase version of the variants, but `Point` is rendered as `"poi"` instead.
339impl fmt::Display for PlaceType {
340    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
341        let quoted = serde_json::to_string(self).unwrap();
342        let inner = &quoted[1..quoted.len() - 1]; // ignore the quote marks
343        write!(f, "{}", inner)
344    }
345}
346
347///Display impl to make `to_string()` format the enum for sending to Twitter. This turns `Meters`
348///into the contained number by itself, and `Feet` into the number suffixed by `"ft"`.
349impl fmt::Display for Accuracy {
350    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
351        match *self {
352            Accuracy::Meters(dist) => write!(f, "{}", dist),
353            Accuracy::Feet(dist) => write!(f, "{}ft", dist),
354        }
355    }
356}
357
358mod serde_bounding_box {
359    use serde::de::Error;
360    use serde::{Deserialize, Deserializer, Serialize, Serializer};
361
362    pub fn deserialize<'de, D>(ser: D) -> Result<Vec<(f64, f64)>, D::Error>
363    where
364        D: Deserializer<'de>,
365    {
366        let s = serde_json::Value::deserialize(ser)?;
367        if s.is_null() {
368            Ok(vec![])
369        } else {
370            s.get("coordinates")
371                .and_then(|arr| arr.get(0).cloned())
372                .ok_or_else(|| D::Error::custom("Malformed 'bounding_box' attribute"))
373                .and_then(|inner_arr| {
374                    serde_json::from_value::<Vec<(f64, f64)>>(inner_arr).map_err(D::Error::custom)
375                })
376        }
377    }
378
379    pub fn serialize<S>(src: &[(f64, f64)], ser: S) -> Result<S::Ok, S::Error>
380    where
381        S: Serializer,
382    {
383        let value = if src.is_empty() {
384            None
385        } else if src.len() == 1 {
386            Some(serde_json::json!({ "coordinates": src, "type": "Point" }))
387        } else {
388            Some(serde_json::json!({ "coordinates": [src], "type": "Polygon" }))
389        };
390        value.serialize(ser)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::common::tests::load_file;
398
399    #[derive(Debug, Clone, Deserialize, Serialize)]
400    struct BoundingBox {
401        #[serde(with = "serde_bounding_box")]
402        bounding_box: Vec<(f64, f64)>,
403    }
404
405    #[test]
406    fn parse_and_serialize_polygon_bounding_box() {
407        let content = load_file("sample_payloads/bounding_box-polygon.json");
408        let bounding_box = ::serde_json::from_str::<BoundingBox>(&content).unwrap();
409        assert_eq!(bounding_box.bounding_box.len(), 4);
410
411        let raw_value: serde_json::Value = ::serde_json::from_str(&content).unwrap();
412        let serialized_value = ::serde_json::to_value(&bounding_box).unwrap();
413        assert_eq!(raw_value, serialized_value);
414    }
415}