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(¶ms));
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(¶ms));
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 = "ed[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}