Skip to main content

everymap_providers_mapbox/domain/search/
mod.rs

1pub mod types;
2
3use crate::client::MapBoxClient;
4use async_trait::async_trait;
5use everymap_core::domains::search::{
6    GeocodeOptions, Geocoder, ReverseGeocodeOptions, SearchResponse, SearchResult, SearchResultType,
7};
8use everymap_core::error::EveryMapResult;
9use everymap_core::types::{Address, BoundingBox, Coordinate};
10use std::sync::Arc;
11
12pub use types::*;
13
14const SEARCH_BASE_URL: &str = "https://api.mapbox.com";
15
16/// Implementation of Geocoder for MapBox Geocoding API v6.
17pub struct MapBoxGeocoder {
18    pub(crate) client: Arc<MapBoxClient>,
19    pub(crate) base_url: String,
20}
21
22impl MapBoxGeocoder {
23    pub fn new(client: Arc<MapBoxClient>) -> Self {
24        Self {
25            client,
26            base_url: SEARCH_BASE_URL.to_string(),
27        }
28    }
29
30    pub fn with_base_url(client: Arc<MapBoxClient>, base_url: String) -> Self {
31        Self { client, base_url }
32    }
33}
34
35/// Convert MapBox v6 feature_type string to core SearchResultType.
36fn classify_feature_type(feature_type: &str) -> SearchResultType {
37    match feature_type {
38        "address" | "poi" => SearchResultType::ExactMatch,
39        "place" | "locality" | "neighborhood" | "region" | "district" | "country" => {
40            SearchResultType::Approximate
41        }
42        _ => SearchResultType::Unknown,
43    }
44}
45
46impl From<MapBoxFeature> for SearchResult {
47    fn from(f: MapBoxFeature) -> Self {
48        let props = f.properties.as_ref();
49
50        // Use properties.coordinates (v6), fallback to geometry coordinates
51        let coordinate = props
52            .and_then(|p| p.coordinates.as_ref())
53            .map(|c| Coordinate::new(c.latitude, c.longitude).unwrap_or(Coordinate::ORIGIN))
54            .or_else(|| {
55                // Fallback: extract from geometry coordinates
56                f.geometry.as_ref().and_then(|g| {
57                    g.coordinates.as_ref().and_then(|coords| {
58                        coords.as_array().and_then(|arr| {
59                            if arr.len() >= 2 {
60                                Some(
61                                    Coordinate::new(
62                                        arr[1].as_f64().unwrap_or(0.0),
63                                        arr[0].as_f64().unwrap_or(0.0),
64                                    )
65                                    .unwrap_or(Coordinate::ORIGIN),
66                                )
67                            } else {
68                                None
69                            }
70                        })
71                    })
72                })
73            })
74            .unwrap_or(Coordinate::ORIGIN);
75
76        // Use properties.full_address as title, fallback to name
77        let title = props.and_then(|p| p.full_address.clone().or(p.name.clone()));
78
79        // Build address from context
80        let mut address = Address::empty();
81        if let Some(p) = props {
82            address.label = p.full_address.clone();
83            address.street = p.name.clone();
84            if let Some(ctx) = &p.context {
85                if let Some(street) = &ctx.street {
86                    address.street = Some(street.name.clone().unwrap_or_default());
87                }
88                if let Some(region) = &ctx.region {
89                    address.state = region.name.clone();
90                }
91                if let Some(country) = &ctx.country {
92                    address.country = country.name.clone();
93                    address.country_code = country.country_code.clone();
94                }
95                if let Some(place) = &ctx.place {
96                    address.city = Some(place.name.clone().unwrap_or_default());
97                }
98                if let Some(district) = &ctx.district {
99                    address.district = district.name.clone();
100                }
101                if let Some(postcode) = &ctx.postcode {
102                    address.postal_code = postcode.name.clone();
103                }
104            }
105            if let Some(house) = &p.address {
106                address.house_number = Some(house.clone());
107            }
108        }
109
110        // Use properties.bbox (v6)
111        let bounding_box = props.and_then(|p| p.bbox.as_ref()).and_then(|b| {
112            if b.len() >= 4 {
113                Some(BoundingBox::new(
114                    Coordinate::new(b[3], b[2]).ok()?, // north_east
115                    Coordinate::new(b[1], b[0]).ok()?, // south_west
116                ))
117            } else {
118                None
119            }
120        });
121
122        let feature_type_str = props.and_then(|p| p.feature_type.as_deref().map(String::from));
123        let result_type = feature_type_str
124            .as_deref()
125            .map(classify_feature_type)
126            .unwrap_or(SearchResultType::Unknown);
127
128        let categories = props
129            .and_then(|p| {
130                let mut cats = Vec::new();
131                if let Some(ft) = &p.feature_type {
132                    cats.push(ft.clone());
133                }
134                if let Some(additional) = &p.additional_feature_types {
135                    cats.extend(additional.iter().cloned());
136                }
137                if cats.is_empty() {
138                    None
139                } else {
140                    Some(cats)
141                }
142            })
143            .unwrap_or_default();
144
145        SearchResult {
146            id: f.id.or(props.and_then(|p| p.mapbox_id.clone())),
147            coordinate,
148            address,
149            title,
150            result_type,
151            distance: None,
152            confidence: props.and_then(|p| p.relevance),
153            categories,
154            bounding_box,
155            raw: None,
156        }
157    }
158}
159
160impl From<MapBoxSearchResponse> for SearchResponse {
161    fn from(response: MapBoxSearchResponse) -> Self {
162        SearchResponse {
163            items: response.features.into_iter().map(|f| f.into()).collect(),
164        }
165    }
166}
167
168#[async_trait]
169impl Geocoder for MapBoxGeocoder {
170    async fn geocode(
171        &self,
172        query: &str,
173        options: &GeocodeOptions,
174    ) -> EveryMapResult<SearchResponse> {
175        let url = format!("{}/search/geocode/v6/forward", self.base_url);
176        let mut params: Vec<(&str, String)> = vec![("q", query.to_string())];
177
178        if let Some(limit) = options.limit {
179            params.push(("limit", limit.to_string()));
180        }
181        if let Some(lang) = &options.language {
182            params.push(("language", lang.clone()));
183        }
184        if !options.country_codes.is_empty() {
185            let codes: String = options.country_codes.join(",");
186            params.push(("country", codes));
187        }
188        if let Some(bbox) = &options.bounding_box {
189            params.push((
190                "bbox",
191                format!(
192                    "{},{},{},{}",
193                    bbox.south_west.lng,
194                    bbox.south_west.lat,
195                    bbox.north_east.lng,
196                    bbox.north_east.lat
197                ),
198            ));
199        }
200        if let Some(extra) = &options.provider_extra {
201            if let Some(obj) = extra.as_object() {
202                if let Some(v) = obj.get("proximity").and_then(|v| v.as_str()) {
203                    params.push(("proximity", v.to_string()));
204                }
205                if let Some(v) = obj.get("types").and_then(|v| v.as_str()) {
206                    params.push(("types", v.to_string()));
207                }
208                if let Some(v) = obj.get("worldview").and_then(|v| v.as_str()) {
209                    params.push(("worldview", v.to_string()));
210                }
211            }
212        }
213
214        let builder = self
215            .client
216            .build_request(reqwest::Method::GET, &url)
217            .query(&params);
218
219        let result: MapBoxSearchResponse = self.client.request_json(builder).await?;
220        Ok(result.into())
221    }
222
223    async fn reverse_geocode(
224        &self,
225        coordinate: &Coordinate,
226        options: &ReverseGeocodeOptions,
227    ) -> EveryMapResult<SearchResponse> {
228        let url = format!("{}/search/geocode/v6/reverse", self.base_url);
229        let mut params: Vec<(&str, String)> = vec![
230            ("longitude", coordinate.lng.to_string()),
231            ("latitude", coordinate.lat.to_string()),
232        ];
233
234        if let Some(limit) = options.limit {
235            params.push(("limit", limit.to_string()));
236        }
237        if let Some(lang) = &options.language {
238            params.push(("language", lang.clone()));
239        }
240        if let Some(radius) = options.radius {
241            log::warn!(
242                "MapBox Reverse Geocoding v6 does not support radius constraints; \
243                 radius ({}) will be ignored",
244                radius
245            );
246        }
247        if let Some(extra) = &options.provider_extra {
248            if let Some(obj) = extra.as_object() {
249                if let Some(v) = obj.get("types").and_then(|v| v.as_str()) {
250                    params.push(("types", v.to_string()));
251                }
252                if let Some(v) = obj.get("worldview").and_then(|v| v.as_str()) {
253                    params.push(("worldview", v.to_string()));
254                }
255            }
256        }
257
258        let builder = self
259            .client
260            .build_request(reqwest::Method::GET, &url)
261            .query(&params);
262
263        let result: MapBoxSearchResponse = self.client.request_json(builder).await?;
264        Ok(result.into())
265    }
266}